mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
draft: database layer refactor (#1054)
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: start database layer refactor * docs: plan database layer refactor * docs: audit database layer refactor phase zero * chore: add database runtime adapter skeleton * chore: add settings repository skeleton * chore: add user session repository skeleton * chore: add host credential repository skeleton * chore: add field encryption boundary * chore: migrate settings route slice * chore: migrate user settings routes * chore: migrate host metrics settings routes * chore: migrate acme settings route * chore: migrate terminal settings route * chore: migrate tailscale settings read * chore: migrate guacamole settings reads * chore: migrate session timeout settings reads * chore: migrate auth route settings reads * chore: migrate host metrics settings reads * chore: migrate startup settings reads * chore: migrate user settings cleanup * chore: migrate password reset settings * chore: migrate oidc legacy settings read * chore: migrate user route settings slice * chore: migrate oidc state settings * chore: migrate user login settings reads * chore: migrate user crypto settings * chore: consolidate startup settings defaults * chore: consolidate database settings import export * chore: migrate core session auth paths * chore: migrate remaining session auth paths * chore: migrate admin user routes * chore: migrate user route admin checks * chore: migrate user lifecycle routes * chore: migrate auth user lookups * chore: migrate oidc user routes * chore: migrate api key repository paths * docs: add database gray rollout guide * chore: migrate trusted device paths * chore: migrate user session route user lookups * chore: add database repository rollout guard * chore: expose repository rollout status * chore: warn on repository rollout misconfiguration * chore: migrate remaining user lookup helpers * chore: migrate ssh user lookups * chore: migrate user settings admin lookups * chore: migrate acme ssl user lookups * chore: migrate audit log admin checks * chore: migrate oidc account user updates * chore: migrate password reset user updates * chore: migrate user deletion core records * chore: migrate snippet audit user lookups * chore: migrate ldap user sync paths * chore: migrate totp user updates * chore: migrate rbac user checks * chore: migrate rbac role paths * chore: migrate permission role lookups * chore: migrate rbac access list reads * chore: migrate shared rbac reads * chore: migrate rbac access writes * chore: migrate permission host access * chore: migrate role host access lookup * chore: migrate snippet access lookup * chore: migrate shared credential access lookups * chore: migrate host access cleanup writes * chore: migrate host list access checks * chore: migrate host access cleanup routes * chore: migrate shared credential role lookups * chore: migrate user role cleanup * chore: migrate admin role sync * chore: migrate ldap role sync * chore: migrate user role assignment * chore: migrate sso provider access * chore: migrate audit log access * chore: migrate user preference access * chore: migrate open tab access * chore: migrate dismissed alert access * chore: migrate homepage layout access * chore: migrate network topology access * chore: migrate dashboard service link access * chore: migrate command history access * chore: migrate recent activity cleanup * chore: migrate ssh credential usage access * chore: migrate transfer recent access * chore: migrate file manager bookmark access * chore: migrate c2s tunnel preset access * chore: migrate homepage item access * chore: migrate session recording access * chore: migrate tmux session tag access * chore: migrate opkssh token access * chore: migrate vault token access * chore: migrate vault profile access * chore: migrate host metrics preference access * chore: migrate host health access * chore: migrate host metrics history access * chore: migrate alert persistence access * chore: route alert host lookup through repository * chore: migrate user data export reads * chore: route host metrics stats sync through repository * chore: migrate host folder persistence * chore: migrate host resolution reads * chore: route jump host resolution reads * chore: route docker console jump host reads * chore: route docker ssh resolution reads * chore: route proxmox discovery resolution reads * chore: route file manager activity host reads * chore: route host metrics resolution reads * chore: route ssh auth credential reads * chore: route tunnel endpoint credential reads * chore: route credential deployment resolution reads * chore: route command history host flag reads * chore: route snippet execution resolution reads * chore: route terminal host resolution reads * chore: route vault oidc host resolution reads * chore: route wake on lan host reads * chore: route internal host list reads * chore: route host key verification persistence * chore: route credential read paths * chore: route credential host usage reads * chore: route credential folder rename * chore: route host owner access checks * chore: route shared credential source reads * chore: route user host credential cleanup * chore: route credential delete reads * chore: route credential update reads * chore: route host credential reads * chore: route host read paths * chore: route host projection reads * chore: route host list reads * chore: route snippet read paths * chore: route snippet folder writes * chore: route snippet crud paths * chore: route snippet bulk import * chore: route rbac ownership reads * chore: route user count reads * chore: route cleanup snippets folders * chore: route shared credential persistence * chore: route dashboard activity * chore: route guacamole host reads * chore: route host bulk lookups * chore: remove unlock-only simple db ops * chore: route host autostart persistence * chore: route ldap provisioning through users * chore: route credential encrypted writes * chore: route host encrypted writes * chore: route bulk host encrypted writes * chore: route termix id credentials * chore: route termix id ca persistence * chore: route termix identity persistence * chore: route credential system migration * chore: isolate user encryption migration storage * chore: remove legacy simple db ops * chore: isolate legacy sqlite migration copy * chore: route database settings import export * chore: route database host credential export * chore: route database host credential import * chore: route database file-manager import export * chore: route database alert usage import export * chore: route database user checks * chore: isolate auth lazy migration storage * chore: route explicit database saves * chore: initialize database save boundary * chore: route migration snapshot saves * chore: isolate sqlite import constraints * chore: route import sqlite boundary * chore: route user encryption migration store * chore: centralize current repository runtime * chore: route more current repositories * chore: route activity repository runtimes * chore: route token repository runtimes * chore: route health repository runtimes * chore: route identity repository runtimes * chore: route rbac repository runtime * chore: centralize current sqlite runtime access * chore: route user deletion key cleanup * chore: route user deletion vault cleanup * chore: route user deletion homepage cleanup * chore: route user deletion health cleanup * chore: route user deletion alert cleanup * chore: route user deletion identity cleanup * chore: add database layer preupgrade backup * Fix database repository type errors * fix: complete post-merge compile fixes for database refactor Restore missing DatabaseSaveTrigger/getDb imports, session log format fallback, OIDC provider resolution, guacamole recording insert, and passwordFallbackOnly typing after merging current dev. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io>
This commit is contained in:
+57
-116
@@ -1,4 +1,4 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { createCurrentAlertRepository } from "../database/repositories/current-alert-repository.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import {
|
||||
sendNotification,
|
||||
@@ -28,25 +28,6 @@ interface AlertRule {
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
interface RawRule {
|
||||
id: number;
|
||||
user_id: string;
|
||||
host_id: number | null;
|
||||
name: string;
|
||||
enabled: number;
|
||||
trigger_type: string;
|
||||
threshold_value: number | null;
|
||||
threshold_duration_seconds: number | null;
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
interface RawChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: number;
|
||||
}
|
||||
|
||||
export class AlertEngine {
|
||||
private static instance: AlertEngine;
|
||||
|
||||
@@ -74,7 +55,7 @@ export class AlertEngine {
|
||||
disk?: { percent: number | null } | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const rules = this.loadRulesForHost(hostId).filter((r) =>
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter((r) =>
|
||||
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
|
||||
r.triggerType,
|
||||
),
|
||||
@@ -106,9 +87,9 @@ export class AlertEngine {
|
||||
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
|
||||
if (
|
||||
now - breachStart >= durationMs &&
|
||||
!this.isCoolingDown(rule.id, hostId)
|
||||
!(await this.isCoolingDown(rule.id, hostId))
|
||||
) {
|
||||
const hostName = this.getHostName(hostId);
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
value: currentValue,
|
||||
message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`,
|
||||
@@ -133,13 +114,13 @@ export class AlertEngine {
|
||||
const triggerType: AlertTriggerType = isOnline
|
||||
? "host_online"
|
||||
: "host_offline";
|
||||
const rules = this.loadRulesForHost(hostId).filter(
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!this.isCoolingDown(rule.id, hostId)) {
|
||||
const hostName = this.getHostName(hostId);
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: isOnline
|
||||
? `Host "${hostName}" is back online`
|
||||
@@ -169,13 +150,13 @@ export class AlertEngine {
|
||||
|
||||
if (!triggerType) return;
|
||||
|
||||
const rules = this.loadRulesForHostUser(hostId, userId).filter(
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!this.isCoolingDown(rule.id, hostId)) {
|
||||
const hostName = this.getHostName(hostId);
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: ok
|
||||
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
|
||||
@@ -192,13 +173,13 @@ export class AlertEngine {
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
const rules = this.loadRulesForHostUser(hostId, userId).filter(
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === "user_login",
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!this.isCoolingDown(rule.id, hostId)) {
|
||||
const hostName = this.getHostName(hostId);
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
|
||||
severity: "info",
|
||||
@@ -233,28 +214,18 @@ export class AlertEngine {
|
||||
};
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
db.$client
|
||||
.prepare(
|
||||
`INSERT INTO alert_firings (user_id, rule_id, host_id, host_name, value, message, severity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
rule.userId,
|
||||
rule.id,
|
||||
hostId,
|
||||
hostName,
|
||||
context.value ?? null,
|
||||
context.message,
|
||||
context.severity,
|
||||
);
|
||||
const repository = createCurrentAlertRepository();
|
||||
await repository.createFiring({
|
||||
userId: rule.userId,
|
||||
ruleId: rule.id,
|
||||
hostId,
|
||||
hostName,
|
||||
value: context.value ?? null,
|
||||
message: context.message,
|
||||
severity: context.severity,
|
||||
});
|
||||
|
||||
// Prune old firings beyond 30 days
|
||||
db.$client
|
||||
.prepare(
|
||||
`DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', '-30 days')`,
|
||||
)
|
||||
.run(rule.userId);
|
||||
repository.pruneFiringsOlderThan(rule.userId, 30);
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to write alert firing", {
|
||||
operation: "alert_firing_insert_error",
|
||||
@@ -263,7 +234,7 @@ export class AlertEngine {
|
||||
});
|
||||
}
|
||||
|
||||
const channels = this.loadChannelsForRule(rule.id);
|
||||
const channels = await this.loadChannelsForRule(rule.id);
|
||||
for (const channel of channels) {
|
||||
sendNotification(channel, payload).catch((err) => {
|
||||
statsLogger.warn("Failed to send notification", {
|
||||
@@ -275,11 +246,14 @@ export class AlertEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private isCoolingDown(ruleId: number, hostId: number): boolean {
|
||||
private async isCoolingDown(
|
||||
ruleId: number,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
const key = `${ruleId}:${hostId}`;
|
||||
const lastFired = this.cooldownMap.get(key);
|
||||
if (!lastFired) return false;
|
||||
const rule = this.loadRuleById(ruleId);
|
||||
const rule = await this.loadRuleById(ruleId);
|
||||
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
|
||||
return Date.now() - lastFired < cooldownMs;
|
||||
}
|
||||
@@ -288,93 +262,60 @@ export class AlertEngine {
|
||||
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
|
||||
}
|
||||
|
||||
private loadRulesForHost(hostId: number): AlertRule[] {
|
||||
private async loadRulesForHost(hostId: number): Promise<AlertRule[]> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.$client
|
||||
.prepare(
|
||||
`SELECT * FROM alert_rules
|
||||
WHERE enabled = 1 AND (host_id = ? OR host_id IS NULL)`,
|
||||
)
|
||||
.all(hostId) as RawRule[];
|
||||
return rows.map(mapRawRule);
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHost(
|
||||
hostId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private loadRulesForHostUser(hostId: number, userId: string): AlertRule[] {
|
||||
private async loadRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertRule[]> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.$client
|
||||
.prepare(
|
||||
`SELECT * FROM alert_rules
|
||||
WHERE enabled = 1 AND user_id = ? AND (host_id = ? OR host_id IS NULL)`,
|
||||
)
|
||||
.all(userId, hostId) as RawRule[];
|
||||
return rows.map(mapRawRule);
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHostUser(
|
||||
hostId,
|
||||
userId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private loadRuleById(ruleId: number): AlertRule | null {
|
||||
private async loadRuleById(ruleId: number): Promise<AlertRule | null> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare("SELECT * FROM alert_rules WHERE id = ?")
|
||||
.get(ruleId) as RawRule | undefined;
|
||||
return row ? mapRawRule(row) : null;
|
||||
return (await createCurrentAlertRepository().findRuleById(
|
||||
ruleId,
|
||||
)) as AlertRule | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadChannelsForRule(ruleId: number): NotificationChannel[] {
|
||||
private async loadChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<NotificationChannel[]> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.$client
|
||||
.prepare(
|
||||
`SELECT nc.id, nc.type, nc.config, nc.enabled
|
||||
FROM notification_channels nc
|
||||
JOIN alert_rule_channels arc ON arc.channel_id = nc.id
|
||||
WHERE arc.rule_id = ? AND nc.enabled = 1`,
|
||||
)
|
||||
.all(ruleId) as RawChannel[];
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
config: r.config,
|
||||
enabled: r.enabled === 1,
|
||||
}));
|
||||
return await createCurrentAlertRepository().listEnabledChannelsForRule(
|
||||
ruleId,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getHostName(hostId: number): string {
|
||||
private async getHostName(hostId: number): Promise<string> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare("SELECT name, ip FROM ssh_data WHERE id = ?")
|
||||
.get(hostId) as { name: string | null; ip: string } | undefined;
|
||||
return row?.name || row?.ip || `Host #${hostId}`;
|
||||
return (
|
||||
(await createCurrentAlertRepository().getHostDisplayName(hostId)) ??
|
||||
`Host #${hostId}`
|
||||
);
|
||||
} catch {
|
||||
return `Host #${hostId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawRule(r: RawRule): AlertRule {
|
||||
return {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
hostId: r.host_id,
|
||||
name: r.name,
|
||||
enabled: r.enabled === 1,
|
||||
triggerType: r.trigger_type as AlertTriggerType,
|
||||
thresholdValue: r.threshold_value,
|
||||
thresholdDurationSeconds: r.threshold_duration_seconds,
|
||||
cooldownMinutes: r.cooldown_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
@@ -59,22 +56,13 @@ export class SSHAuthManager {
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId),
|
||||
eq(sshCredentials.userId, this.context.userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
this.context.userId,
|
||||
);
|
||||
const cred =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
hostConfig.credentialId,
|
||||
this.context.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0];
|
||||
if (cred) {
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
|
||||
@@ -63,19 +63,11 @@ describe("expandOidcUsername", () => {
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ oidcIdentifier: "jdoe" }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
vi.doMock("../database/repositories/current-user-repository.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ oidcIdentifier: "jdoe" }),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("./credential-username.js");
|
||||
@@ -83,19 +75,11 @@ describe("expandOidcUsername", () => {
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ oidcIdentifier: null }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
vi.doMock("../database/repositories/current-user-repository.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async () => ({ oidcIdentifier: null }),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("./credential-username.js");
|
||||
@@ -105,8 +89,8 @@ describe("expandOidcUsername", () => {
|
||||
});
|
||||
|
||||
it("returns the username unchanged when the DB lookup throws", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => {
|
||||
vi.doMock("../database/repositories/current-user-repository.js", () => ({
|
||||
createCurrentUserRepository: () => {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -48,18 +48,10 @@ export async function expandOidcUsername(
|
||||
}
|
||||
|
||||
try {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ oidcIdentifier: users.oidcIdentifier })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const oidcIdentifier = rows[0]?.oidcIdentifier;
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/current-user-repository.js");
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
const oidcIdentifier = user?.oidcIdentifier;
|
||||
if (!oidcIdentifier) return username;
|
||||
|
||||
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
@@ -92,24 +89,17 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
const jumpHostData = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (jumpHostData.length === 0) {
|
||||
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
|
||||
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = jumpHostData[0] as unknown as SSHHost;
|
||||
const jumpHost = resolvedJumpHost as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
@@ -134,22 +124,12 @@ async function createJumpHostChain(
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, jumpHost.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
jumpHost.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
|
||||
@@ -16,6 +16,20 @@ type FileContentRoutesDeps = {
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
const getRequiredQueryParam = (
|
||||
value: string | string[] | undefined,
|
||||
): string | undefined => {
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseByteOffset = (value: string | undefined): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export function registerFileContentRoutes(
|
||||
app: Express,
|
||||
{ sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
|
||||
@@ -1512,264 +1526,125 @@ export function registerFileContentRoutes(
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/uploadFileChunk:
|
||||
* post:
|
||||
* summary: Upload a single chunk of a large file
|
||||
* description: Receives one chunk of a large file upload via multipart form data and either creates a new remote file (chunkIndex=0) or appends to the existing file (chunkIndex>0). Used by the client to upload files larger than ~2GB that would otherwise hit browser-side ArrayBuffer limits.
|
||||
* summary: Upload one raw file chunk
|
||||
* description: Writes a raw request body to the remote file at the supplied byte offset, allowing browser clients to avoid multipart/FormData 2GB limits.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - path
|
||||
* - fileName
|
||||
* - chunkIndex
|
||||
* - totalChunks
|
||||
* - totalSize
|
||||
* - chunk
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* fileName:
|
||||
* type: string
|
||||
* chunkIndex:
|
||||
* type: integer
|
||||
* totalChunks:
|
||||
* type: integer
|
||||
* totalSize:
|
||||
* type: integer
|
||||
* chunk:
|
||||
* type: string
|
||||
* format: binary
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Chunk accepted. When chunkIndex === totalChunks - 1 the full file is complete.
|
||||
* 400:
|
||||
* description: Missing required parameters, invalid chunk metadata, or SSH connection not established.
|
||||
* 403:
|
||||
* description: Session access denied.
|
||||
* 500:
|
||||
* description: Failed to write chunk to remote filesystem.
|
||||
*/
|
||||
app.post(
|
||||
"/ssh/file_manager/ssh/uploadFileChunk",
|
||||
(req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const contentType = req.headers["content-type"] || "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Expected multipart/form-data request" });
|
||||
}
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let remotePath: string | undefined;
|
||||
let fileName: string | undefined;
|
||||
let chunkIndex = -1;
|
||||
let totalChunks = 0;
|
||||
let totalSize = 0;
|
||||
let resolved = false;
|
||||
let chunkStartTime = Date.now();
|
||||
|
||||
const bb = Busboy({
|
||||
headers: req.headers,
|
||||
limits: {
|
||||
fileSize: 32 * 1024 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
bb.on("field", (name: string, value: string) => {
|
||||
if (name === "sessionId") sessionId = value;
|
||||
else if (name === "path") remotePath = value;
|
||||
else if (name === "fileName") fileName = value;
|
||||
else if (name === "chunkIndex") {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(n) && n >= 0) chunkIndex = n;
|
||||
} else if (name === "totalChunks") {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(n) && n > 0) totalChunks = n;
|
||||
} else if (name === "totalSize") {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(n) && n >= 0) totalSize = n;
|
||||
}
|
||||
});
|
||||
|
||||
bb.on(
|
||||
"file",
|
||||
(
|
||||
fieldname: string,
|
||||
fileStream: NodeJS.ReadableStream,
|
||||
_info: { filename: string; encoding: string; mimeType: string },
|
||||
) => {
|
||||
if (
|
||||
!sessionId ||
|
||||
!remotePath ||
|
||||
!fileName ||
|
||||
chunkIndex < 0 ||
|
||||
totalChunks <= 0 ||
|
||||
chunkIndex >= totalChunks
|
||||
) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(400).json({
|
||||
error:
|
||||
"Missing or invalid chunk metadata (sessionId, path, fileName, chunkIndex, totalChunks required)",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
chunkStartTime = Date.now();
|
||||
|
||||
const fullPath = remotePath.endsWith("/")
|
||||
? remotePath + fileName
|
||||
: remotePath + "/" + fileName;
|
||||
|
||||
const flags = chunkIndex === 0 ? "w" : "a";
|
||||
|
||||
fileLogger.info("Chunk upload started", {
|
||||
operation: "file_upload_chunk_start",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
totalSize,
|
||||
});
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
const writeStream = sftp.createWriteStream(fullPath, {
|
||||
flags,
|
||||
});
|
||||
|
||||
let chunkBytes = 0;
|
||||
|
||||
fileStream.on("data", (data: Buffer) => {
|
||||
chunkBytes += data.length;
|
||||
});
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
fileLogger.error(
|
||||
"SFTP write stream error during chunk upload:",
|
||||
err,
|
||||
);
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Chunk upload failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
const isFinalChunk = chunkIndex === totalChunks - 1;
|
||||
fileLogger.success(
|
||||
isFinalChunk
|
||||
? "Chunked file upload completed"
|
||||
: "Chunk upload completed",
|
||||
{
|
||||
operation: isFinalChunk
|
||||
? "file_upload_chunked_complete"
|
||||
: "file_upload_chunk_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
chunkBytes,
|
||||
totalSize,
|
||||
duration: Date.now() - chunkStartTime,
|
||||
},
|
||||
);
|
||||
res.json({
|
||||
message: isFinalChunk
|
||||
? "File uploaded successfully"
|
||||
: "Chunk accepted",
|
||||
path: fullPath,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
chunkBytes,
|
||||
complete: isFinalChunk,
|
||||
toast: isFinalChunk
|
||||
? {
|
||||
type: "success",
|
||||
message: `File uploaded: ${fullPath}`,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
fileStream.on("error", (err) => {
|
||||
fileLogger.error(
|
||||
"File read stream error during chunk upload:",
|
||||
err,
|
||||
);
|
||||
writeStream.destroy();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(500).json({
|
||||
error: `Chunk upload stream error: ${err.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
(fileStream as NodeJS.ReadableStream).pipe(
|
||||
writeStream as unknown as NodeJS.WritableStream,
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileStream.resume();
|
||||
fileLogger.error("SFTP session error during chunk upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(500).json({ error: `SFTP error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
},
|
||||
const sessionId = getRequiredQueryParam(req.query.sessionId as string);
|
||||
const remotePath = getRequiredQueryParam(req.query.path as string);
|
||||
const fileName = getRequiredQueryParam(req.query.fileName as string);
|
||||
const offset = parseByteOffset(
|
||||
getRequiredQueryParam(req.query.offset as string),
|
||||
);
|
||||
const totalSize = parseByteOffset(
|
||||
getRequiredQueryParam(req.query.totalSize as string),
|
||||
);
|
||||
|
||||
bb.on("error", (err: Error) => {
|
||||
fileLogger.error("Busboy parse error during chunk upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Chunk upload parse error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
if (!sessionId || !remotePath || !fileName) {
|
||||
req.resume();
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing sessionId, path, or fileName" });
|
||||
}
|
||||
|
||||
req.pipe(bb);
|
||||
if (offset === null || totalSize === null || offset > totalSize) {
|
||||
req.resume();
|
||||
return res.status(400).json({ error: "Invalid upload offset" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
req.resume();
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
req.resume();
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
const fullPath = remotePath.endsWith("/")
|
||||
? remotePath + fileName
|
||||
: remotePath + "/" + fileName;
|
||||
let resolved = false;
|
||||
let bytesWritten = 0;
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
const writeStream = sftp.createWriteStream(fullPath, {
|
||||
flags: offset === 0 ? "w" : "r+",
|
||||
start: offset,
|
||||
});
|
||||
|
||||
const fail = (status: number, error: string) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
req.unpipe(writeStream as unknown as NodeJS.WritableStream);
|
||||
writeStream.destroy();
|
||||
res.status(status).json({ error });
|
||||
};
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
fileLogger.error(
|
||||
"SFTP write stream error during chunk upload:",
|
||||
err,
|
||||
);
|
||||
fail(500, `Upload failed: ${err.message}`);
|
||||
});
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
const nextOffset = offset + bytesWritten;
|
||||
fileLogger.info("File chunk upload completed", {
|
||||
operation: "file_upload_chunk_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
offset,
|
||||
bytesWritten,
|
||||
nextOffset,
|
||||
totalSize,
|
||||
duration: Date.now() - uploadStartTime,
|
||||
});
|
||||
res.json({
|
||||
message: "File chunk uploaded successfully",
|
||||
path: fullPath,
|
||||
offset,
|
||||
bytesWritten,
|
||||
nextOffset,
|
||||
complete: nextOffset >= totalSize,
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
fileLogger.error("Request stream error during chunk upload:", err);
|
||||
fail(500, `Upload stream error: ${err.message}`);
|
||||
});
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
bytesWritten += chunk.length;
|
||||
});
|
||||
|
||||
req.pipe(writeStream as unknown as NodeJS.WritableStream);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
req.resume();
|
||||
fileLogger.error("SFTP session error during chunk upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(500).json({ error: `SFTP error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,8 @@ import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
|
||||
import {
|
||||
@@ -1178,18 +1175,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
if (hostId && userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hostResults.length > 0 && hostResults[0].name
|
||||
? hostResults[0].name
|
||||
host?.userId === userId && host.name
|
||||
? host.name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -1877,23 +1871,15 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.id, session.hostId!),
|
||||
eq(hosts.userId, session.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
session.userId!,
|
||||
);
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
session.hostId!,
|
||||
session.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hostResults.length > 0 && hostResults[0].name
|
||||
? hostResults[0].name
|
||||
host?.userId === session.userId && host.name
|
||||
? host.name
|
||||
: `${session.username}@${session.ip}:${session.port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -2084,23 +2070,15 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.id, session.hostId!),
|
||||
eq(hosts.userId, session.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
session.userId!,
|
||||
);
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
session.hostId!,
|
||||
session.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hostResults.length > 0 && hostResults[0].name
|
||||
? hostResults[0].name
|
||||
host?.userId === session.userId && host.name
|
||||
? host.name
|
||||
: `${session.username}@${session.ip}:${session.port}`;
|
||||
|
||||
await axios.post(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
interface HostKeyVerificationData {
|
||||
@@ -36,17 +34,9 @@ export class SSHHostKeyVerifier {
|
||||
} | null> {
|
||||
if (!hostId) return null;
|
||||
try {
|
||||
const host = await db.query.hosts.findFirst({
|
||||
where: eq(hosts.id, hostId),
|
||||
columns: {
|
||||
hostKeyFingerprint: true,
|
||||
hostKeyType: true,
|
||||
hostKeyAlgorithm: true,
|
||||
hostKeyChangedCount: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return host ?? null;
|
||||
return await createCurrentHostResolutionRepository().findHostKeyVerificationData(
|
||||
hostId,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -89,7 +79,9 @@ export class SSHHostKeyVerifier {
|
||||
const host =
|
||||
preloadedHost !== undefined
|
||||
? preloadedHost
|
||||
: await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) });
|
||||
: await createCurrentHostResolutionRepository().findHostKeyVerificationData(
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (!host) {
|
||||
sshLogger.warn(
|
||||
@@ -189,9 +181,8 @@ export class SSHHostKeyVerifier {
|
||||
// Verify first, then update the timestamp asynchronously so the
|
||||
// DB write doesn't delay the SSH key exchange critical path.
|
||||
verify(true);
|
||||
db.update(hosts)
|
||||
.set({ hostKeyLastVerified: new Date().toISOString() })
|
||||
.where(eq(hosts.id, hostId))
|
||||
createCurrentHostResolutionRepository()
|
||||
.touchHostKeyLastVerified(hostId)
|
||||
.catch((err) => {
|
||||
sshLogger.error("Failed to update hostKeyLastVerified", err, {
|
||||
operation: "host_key_update_timestamp",
|
||||
@@ -314,16 +305,12 @@ export class SSHHostKeyVerifier {
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: new Date().toISOString(),
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await createCurrentHostResolutionRepository().storeHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
);
|
||||
}
|
||||
|
||||
private static async updateHostKey(
|
||||
@@ -333,16 +320,13 @@ export class SSHHostKeyVerifier {
|
||||
algorithm: string,
|
||||
currentChangeCount: number,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
await createCurrentHostResolutionRepository().updateHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
currentChangeCount,
|
||||
);
|
||||
}
|
||||
|
||||
private static async promptUserForNewKey(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../database/repositories/current-host-metrics-history-repository.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
|
||||
type HistoryRoutesDeps = {
|
||||
@@ -98,19 +98,24 @@ export function registerHostMetricsHistoryRoutes(
|
||||
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
|
||||
// Normalize both sides to that format for reliable string comparison.
|
||||
const toSqlite = (iso: string) =>
|
||||
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
const rows = db.$client
|
||||
.prepare(
|
||||
`SELECT ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
|
||||
FROM host_metrics_history
|
||||
WHERE host_id = ? AND ts >= ? AND ts <= ?
|
||||
ORDER BY ts ASC`,
|
||||
const rows = (
|
||||
await createCurrentHostMetricsHistoryRepository().listRange(
|
||||
hostId,
|
||||
toSqlite(fromTs),
|
||||
toSqlite(toTs),
|
||||
)
|
||||
.all(hostId, toSqlite(fromTs), toSqlite(toTs));
|
||||
).map((row) => ({
|
||||
ts: row.ts,
|
||||
cpu_percent: row.cpuPercent,
|
||||
mem_percent: row.memPercent,
|
||||
disk_percent: row.diskPercent,
|
||||
net_rx_bytes: row.netRxBytes,
|
||||
net_tx_bytes: row.netTxBytes,
|
||||
}));
|
||||
|
||||
res.json({ rows, fromTs, toTs });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Client } from "ssh2";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
@@ -38,17 +35,14 @@ async function resolveJumpHost(
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
if (!resolvedHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -80,22 +74,12 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const credential = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
);
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { createCurrentHostMetricsPreferenceRepository } from "../database/repositories/current-host-metrics-preference-repository.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
@@ -96,12 +96,11 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT layout FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, hostId) as { layout: string } | undefined;
|
||||
const row =
|
||||
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (row?.layout) {
|
||||
try {
|
||||
@@ -180,29 +179,15 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
return res.status(400).json({ error: "Invalid layout" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
const existing = db.$client
|
||||
.prepare(
|
||||
"SELECT id FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, hostId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE host_metrics_preferences SET layout = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(layoutJson, now, existing.id);
|
||||
} else {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO host_metrics_preferences (user_id, host_id, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(userId, hostId, layoutJson, now, now);
|
||||
}
|
||||
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
|
||||
userId,
|
||||
hostId,
|
||||
layoutJson,
|
||||
now,
|
||||
);
|
||||
|
||||
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
|
||||
// user owns. stats_config is plain JSON text (not an encrypted field).
|
||||
@@ -213,11 +198,11 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
...current,
|
||||
enabledWidgets: deriveEnabledWidgets(layout.slots),
|
||||
};
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE ssh_data SET stats_config = ? WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.run(JSON.stringify(merged), hostId, userId);
|
||||
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
|
||||
userId,
|
||||
hostId,
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
} catch (syncErr) {
|
||||
statsLogger.warn("Failed to sync enabledWidgets from layout", {
|
||||
operation: "host_metrics_prefs_sync_widgets",
|
||||
@@ -228,7 +213,6 @@ export function registerHostMetricsPreferencesRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("host_metrics_preferences_updated");
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save host metrics preferences", {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/current-settings-repository.js";
|
||||
|
||||
type HostMetricsSettingsConfig = {
|
||||
statusCheckInterval: number;
|
||||
@@ -13,6 +13,12 @@ type HostMetricsSettingsRoutesDeps = {
|
||||
refreshAllPolling: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isMissingSettingsTable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes("no such table: settings")
|
||||
);
|
||||
}
|
||||
|
||||
export function registerHostMetricsSettingsRoutes(
|
||||
app: Express,
|
||||
{
|
||||
@@ -36,17 +42,23 @@ export function registerHostMetricsSettingsRoutes(
|
||||
*/
|
||||
app.get("/global-settings", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const statusValue = await settings.get("global_status_check_interval");
|
||||
const metricsValue = await settings.get("global_metrics_interval");
|
||||
|
||||
try {
|
||||
db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
|
||||
} catch (tableError) {
|
||||
res.json({
|
||||
statusCheckInterval: statusValue
|
||||
? parseInt(statusValue, 10)
|
||||
: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: metricsValue
|
||||
? parseInt(metricsValue, 10)
|
||||
: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingSettingsTable(error)) {
|
||||
statsLogger.warn("Settings table does not exist, using defaults", {
|
||||
operation: "global_settings_table_check",
|
||||
error:
|
||||
tableError instanceof Error
|
||||
? tableError.message
|
||||
: String(tableError),
|
||||
error: error.message,
|
||||
});
|
||||
return res.json({
|
||||
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
@@ -54,26 +66,6 @@ export function registerHostMetricsSettingsRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const statusRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_status_check_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const metricsRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_metrics_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
res.json({
|
||||
statusCheckInterval: statusRow
|
||||
? parseInt(statusRow.value, 10)
|
||||
: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: metricsRow
|
||||
? parseInt(metricsRow.value, 10)
|
||||
: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch global settings", {
|
||||
operation: "global_settings_fetch_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -133,40 +125,16 @@ export function registerHostMetricsSettingsRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
|
||||
} catch (tableError) {
|
||||
statsLogger.error(
|
||||
"Settings table does not exist, cannot save settings",
|
||||
{
|
||||
operation: "global_settings_table_check",
|
||||
error:
|
||||
tableError instanceof Error
|
||||
? tableError.message
|
||||
: String(tableError),
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Database settings table is missing. Please check database initialization.",
|
||||
});
|
||||
}
|
||||
const settings = createCurrentSettingsRepository();
|
||||
|
||||
if (statusCheckInterval !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)",
|
||||
)
|
||||
.run(String(statusCheckInterval));
|
||||
await settings.set(
|
||||
"global_status_check_interval",
|
||||
String(statusCheckInterval),
|
||||
);
|
||||
}
|
||||
if (metricsInterval !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)",
|
||||
)
|
||||
.run(String(metricsInterval));
|
||||
await settings.set("global_metrics_interval", String(metricsInterval));
|
||||
}
|
||||
|
||||
await refreshAllPolling();
|
||||
@@ -176,6 +144,20 @@ export function registerHostMetricsSettingsRoutes(
|
||||
message: "Settings updated and polling refreshed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingSettingsTable(error)) {
|
||||
statsLogger.error(
|
||||
"Settings table does not exist, cannot save settings",
|
||||
{
|
||||
operation: "global_settings_table_check",
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Database settings table is missing. Please check database initialization.",
|
||||
});
|
||||
}
|
||||
|
||||
statsLogger.error("Failed to save global settings", {
|
||||
operation: "global_settings_save_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -200,15 +182,12 @@ export function registerHostMetricsSettingsRoutes(
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.get("/global-settings/history", requireAdmin, (_req, res) => {
|
||||
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'metrics_history_retention_days'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const days = row ? parseInt(row.value, 10) : 7;
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
"metrics_history_retention_days",
|
||||
);
|
||||
const days = value ? parseInt(value, 10) : 7;
|
||||
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch history retention setting", {
|
||||
@@ -247,7 +226,7 @@ export function registerHostMetricsSettingsRoutes(
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.post("/global-settings/history", requireAdmin, (req, res) => {
|
||||
app.post("/global-settings/history", requireAdmin, async (req, res) => {
|
||||
const { metricsHistoryRetentionDays } = req.body;
|
||||
if (
|
||||
typeof metricsHistoryRetentionDays !== "number" ||
|
||||
@@ -259,12 +238,10 @@ export function registerHostMetricsSettingsRoutes(
|
||||
});
|
||||
}
|
||||
try {
|
||||
const db = getDb();
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('metrics_history_retention_days', ?)",
|
||||
)
|
||||
.run(String(Math.floor(metricsHistoryRetentionDays)));
|
||||
await createCurrentSettingsRepository().set(
|
||||
"metrics_history_retention_days",
|
||||
String(Math.floor(metricsHistoryRetentionDays)),
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save history retention setting", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
|
||||
type ViewerStatsConfig = {
|
||||
metricsEnabled: boolean;
|
||||
@@ -70,7 +70,7 @@ export function registerHostMetricsViewerRoutes<
|
||||
const { viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -129,7 +129,7 @@ export function registerHostMetricsViewerRoutes<
|
||||
const { hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -259,7 +259,7 @@ export function registerHostMetricsViewerRoutes<
|
||||
const { hostId, viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
|
||||
+52
-134
@@ -8,11 +8,11 @@ import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "./credential-username.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getCurrentSettingValue } from "../database/repositories/current-settings-repository.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../database/repositories/current-host-metrics-history-repository.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
|
||||
@@ -27,7 +27,6 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
@@ -51,7 +50,6 @@ import {
|
||||
supportsMetrics,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "./host-metrics-helpers.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
import {
|
||||
cleanupMetricsSession,
|
||||
getSessionKey,
|
||||
@@ -143,7 +141,6 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 60,
|
||||
@@ -262,26 +259,18 @@ class PollingManager {
|
||||
metricsInterval: number;
|
||||
} {
|
||||
try {
|
||||
const db = getDb();
|
||||
const statusRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_status_check_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const metricsRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_metrics_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const statusValue = getCurrentSettingValue(
|
||||
"global_status_check_interval",
|
||||
);
|
||||
const metricsValue = getCurrentSettingValue("global_metrics_interval");
|
||||
|
||||
return {
|
||||
statusCheckInterval: statusRow
|
||||
? parseInt(statusRow.value, 10) ||
|
||||
statusCheckInterval: statusValue
|
||||
? parseInt(statusValue, 10) ||
|
||||
DEFAULT_STATS_CONFIG.statusCheckInterval
|
||||
: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: metricsRow
|
||||
? parseInt(metricsRow.value, 10) ||
|
||||
DEFAULT_STATS_CONFIG.metricsInterval
|
||||
metricsInterval: metricsValue
|
||||
? parseInt(metricsValue, 10) || DEFAULT_STATS_CONFIG.metricsInterval
|
||||
: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
};
|
||||
} catch {
|
||||
@@ -387,8 +376,6 @@ class PollingManager {
|
||||
viewerUserId,
|
||||
};
|
||||
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
|
||||
if (isTcpPingEnabled(statsConfig)) {
|
||||
const intervalMs = this.intervalWithJitter(
|
||||
statsConfig.statusCheckInterval * 1000,
|
||||
@@ -446,6 +433,8 @@ class PollingManager {
|
||||
} else {
|
||||
this.metricsStore.delete(host.id);
|
||||
}
|
||||
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
}
|
||||
|
||||
private async pollHostStatus(
|
||||
@@ -556,7 +545,7 @@ class PollingManager {
|
||||
data: metrics,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.insertMetricsHistory(refreshedHost.id, metrics);
|
||||
await this.insertMetricsHistory(refreshedHost.id, metrics);
|
||||
AlertEngine.getInstance()
|
||||
.evaluateMetrics(refreshedHost.id, metrics)
|
||||
.catch(() => {});
|
||||
@@ -600,20 +589,15 @@ class PollingManager {
|
||||
|
||||
private getRetentionDays(): number {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'metrics_history_retention_days'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const days = row ? parseInt(row.value, 10) : 7;
|
||||
const value = getCurrentSettingValue("metrics_history_retention_days");
|
||||
const days = value ? parseInt(value, 10) : 7;
|
||||
return isNaN(days) || days < 1 ? 7 : Math.min(days, 90);
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
private insertMetricsHistory(
|
||||
private async insertMetricsHistory(
|
||||
hostId: number,
|
||||
metrics: {
|
||||
cpu: { percent: number | null };
|
||||
@@ -623,34 +607,24 @@ class PollingManager {
|
||||
interfaces: Array<{ rxBytes: string | null; txBytes: string | null }>;
|
||||
};
|
||||
},
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const iface = metrics.network?.interfaces?.[0];
|
||||
const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null;
|
||||
const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null;
|
||||
|
||||
db.$client
|
||||
.prepare(
|
||||
`INSERT INTO host_metrics_history
|
||||
(host_id, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
hostId,
|
||||
metrics.cpu?.percent ?? null,
|
||||
metrics.memory?.percent ?? null,
|
||||
metrics.disk?.percent ?? null,
|
||||
isNaN(rxRaw as number) ? null : rxRaw,
|
||||
isNaN(txRaw as number) ? null : txRaw,
|
||||
);
|
||||
const repository = createCurrentHostMetricsHistoryRepository();
|
||||
await repository.create({
|
||||
hostId,
|
||||
cpuPercent: metrics.cpu?.percent ?? null,
|
||||
memPercent: metrics.memory?.percent ?? null,
|
||||
diskPercent: metrics.disk?.percent ?? null,
|
||||
netRxBytes: isNaN(rxRaw as number) ? null : rxRaw,
|
||||
netTxBytes: isNaN(txRaw as number) ? null : txRaw,
|
||||
});
|
||||
|
||||
const retentionDays = this.getRetentionDays();
|
||||
db.$client
|
||||
.prepare(
|
||||
`DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)`,
|
||||
)
|
||||
.run(hostId, `-${retentionDays} days`);
|
||||
repository.pruneOlderThan(hostId, retentionDays);
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to write metrics history", {
|
||||
operation: "insert_metrics_history",
|
||||
@@ -883,11 +857,8 @@ async function fetchAllHosts(
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials[]> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const hostResults = await repository.findHostsByUserId(userId);
|
||||
|
||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||
for (const host of hostResults) {
|
||||
@@ -915,7 +886,7 @@ async function fetchHostById(
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -934,17 +905,13 @@ async function fetchHostById(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, id)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const host = await repository.findHostById(id, userId);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
return await resolveHostCredentials(host, userId);
|
||||
} catch (err) {
|
||||
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||
@@ -972,15 +939,6 @@ async function resolveHostCredentials(
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
terminalConfig: (() => {
|
||||
try {
|
||||
return typeof host.terminalConfig === "string"
|
||||
? JSON.parse(host.terminalConfig as string)
|
||||
: host.terminalConfig || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(),
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
@@ -1060,17 +1018,13 @@ async function resolveHostCredentials(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, host.credentialId as number)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
const credential =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.authType =
|
||||
credential.authType ||
|
||||
@@ -1276,14 +1230,6 @@ async function buildSshConfig(
|
||||
} else {
|
||||
throw new Error(`Credential for host ${host.ip} could not be resolved`);
|
||||
}
|
||||
} else if (host.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
base as Record<string, unknown>,
|
||||
(host as { terminalConfig?: Record<string, unknown> }).terminalConfig,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
|
||||
@@ -1537,14 +1483,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
};
|
||||
temperature: {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: Array<{
|
||||
label: string;
|
||||
celsius: number;
|
||||
}>;
|
||||
};
|
||||
}> {
|
||||
if (!supportsMetrics(host)) {
|
||||
throw new Error("Metrics collection only supported for SSH hosts");
|
||||
@@ -1575,7 +1513,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
const uptime = await collectUptimeMetrics(client);
|
||||
const processes = await collectProcessesMetrics(client);
|
||||
const system = await collectSystemMetrics(client);
|
||||
const temperature = await collectTemperatureMetrics(client);
|
||||
|
||||
let login_stats = {
|
||||
recentLogins: [],
|
||||
@@ -1647,7 +1584,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
uptime,
|
||||
processes,
|
||||
system,
|
||||
temperature,
|
||||
login_stats,
|
||||
ports,
|
||||
firewall,
|
||||
@@ -1775,7 +1711,7 @@ function tcpPing(
|
||||
app.get("/status", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -1823,7 +1759,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -1865,7 +1801,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
app.post("/clear-connections", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -1893,7 +1829,7 @@ app.post("/clear-connections", requireAdmin, async (req, res) => {
|
||||
app.post("/refresh", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -1937,7 +1873,7 @@ app.post("/host-updated", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -2000,7 +1936,7 @@ app.post("/host-deleted", async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -2050,7 +1986,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -2113,7 +2049,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
|
||||
const connectionLogs: Array<Omit<LogEntry, "id" | "timestamp">> = [];
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog("error", "stats_connecting", "Session expired"),
|
||||
);
|
||||
@@ -2133,20 +2069,6 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
return res.status(404).json({ error: "Host not found", connectionLogs });
|
||||
}
|
||||
|
||||
if (!supportsMetrics(host)) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"stats_connecting",
|
||||
"Metrics collection is only supported for SSH hosts",
|
||||
{
|
||||
connectionType: host.connectionType || "ssh",
|
||||
},
|
||||
),
|
||||
);
|
||||
return res.json({ success: true, skipped: true, connectionLogs });
|
||||
}
|
||||
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -2192,11 +2114,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
"Using existing metrics session",
|
||||
),
|
||||
);
|
||||
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(host.id, viewerSessionId, userId);
|
||||
|
||||
return res.json({ success: true, viewerSessionId, connectionLogs });
|
||||
return res.json({ success: true, connectionLogs });
|
||||
}
|
||||
|
||||
const config = await buildSshConfig(host);
|
||||
@@ -2587,7 +2505,7 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { viewerSessionId } = req.body;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
@@ -2659,7 +2577,7 @@ app.post("/metrics/connect-totp", async (req, res) => {
|
||||
const { sessionId, totpCode } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials, vaultProfiles } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { createCurrentVaultProfileRepository } from "../database/repositories/current-vault-profile-repository.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
@@ -28,17 +26,11 @@ export async function resolveHostById(
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
|
||||
const db = getDb();
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
if (!resolvedHost) return null;
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
db.select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) return null;
|
||||
|
||||
const host = hostResults[0] as Record<string, unknown>;
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
|
||||
// Parse JSON fields
|
||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||
@@ -91,33 +83,16 @@ export async function resolveHostById(
|
||||
// Try user's own override credential first
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { hostAccess } = await import("../database/db/schema.js");
|
||||
const accessRecords = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
const overrideCredId = accessRecords[0]?.overrideCredentialId as
|
||||
| number
|
||||
| null;
|
||||
const overrideCredId = await repository.findOverrideCredentialId(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (overrideCredId) {
|
||||
const userCreds = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, overrideCredId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
overrideCredId,
|
||||
userId,
|
||||
);
|
||||
if (userCreds.length > 0) {
|
||||
const cred = userCreds[0] as Record<string, unknown>;
|
||||
)) as Record<string, unknown> | null;
|
||||
if (cred) {
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
@@ -183,22 +158,12 @@ export async function resolveHostById(
|
||||
return null;
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const cred = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
);
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
if (cred) {
|
||||
host.password = pickResolvedPassword(host.password, cred.password);
|
||||
// Prefer the normalised private key; fall back to raw key field
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
@@ -232,13 +197,11 @@ export async function resolveHostById(
|
||||
// certificate itself is obtained per-user at connect time via Vault OIDC.
|
||||
if (host.vaultProfileId) {
|
||||
try {
|
||||
const profiles = await db
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, host.vaultProfileId as number))
|
||||
.limit(1);
|
||||
if (profiles.length > 0) {
|
||||
(host as Record<string, unknown>).vaultProfile = profiles[0];
|
||||
const profile = await createCurrentVaultProfileRepository().findById(
|
||||
host.vaultProfileId as number,
|
||||
);
|
||||
if (profile) {
|
||||
(host as Record<string, unknown>).vaultProfile = profile;
|
||||
host.authType = "vault";
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
@@ -38,17 +35,14 @@ async function resolveJumpHost(
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
if (!resolvedHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -80,22 +74,12 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const credential = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
);
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Express } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "../../database/db/index.js";
|
||||
import { createCurrentHostHealthRepository } from "../../database/repositories/current-host-health-repository.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import { isValidPort } from "./validation.js";
|
||||
@@ -115,46 +115,20 @@ function recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
): void {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const insert = db.$client.prepare(
|
||||
"INSERT INTO host_health_history (user_id, host_id, check_id, ts, ok, latency_ms, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
for (const r of results) {
|
||||
insert.run(
|
||||
userId,
|
||||
hostId,
|
||||
r.checkId,
|
||||
now,
|
||||
r.ok ? 1 : 0,
|
||||
r.latencyMs,
|
||||
r.detail,
|
||||
);
|
||||
}
|
||||
// Prune to the most recent HISTORY_KEEP rows per (host, check).
|
||||
db.$client
|
||||
.prepare(
|
||||
`DELETE FROM host_health_history
|
||||
WHERE id IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM host_health_history
|
||||
WHERE user_id = ? AND host_id = ?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
)
|
||||
)`,
|
||||
)
|
||||
.run(userId, hostId, userId, hostId, HISTORY_KEEP);
|
||||
): Promise<void> {
|
||||
return createCurrentHostHealthRepository()
|
||||
.recordHistory(userId, hostId, results, HISTORY_KEEP)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
function loadChecks(userId: string, hostId: number): HealthCheck[] {
|
||||
const row = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT checks FROM host_health_checks WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, hostId) as { checks: string } | undefined;
|
||||
async function loadChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
@@ -177,10 +151,10 @@ export function registerHealthRoutes(
|
||||
"health_get",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = loadChecks(userId, host.id);
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
if (results.length) {
|
||||
recordHistory(userId, host.id, results);
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
@@ -194,11 +168,19 @@ export function registerHealthRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const history = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT check_id as checkId, ts, ok, latency_ms as latencyMs, detail FROM host_health_history WHERE user_id = ? AND host_id = ? ORDER BY ts DESC LIMIT 200",
|
||||
const history = (
|
||||
await createCurrentHostHealthRepository().listHistory(
|
||||
userId,
|
||||
host.id,
|
||||
200,
|
||||
)
|
||||
.all(userId, host.id);
|
||||
).map((row) => ({
|
||||
checkId: row.checkId,
|
||||
ts: row.ts,
|
||||
ok: row.ok,
|
||||
latencyMs: row.latencyMs,
|
||||
detail: row.detail,
|
||||
}));
|
||||
return { checks, results, history };
|
||||
},
|
||||
),
|
||||
@@ -226,27 +208,14 @@ export function registerHealthRoutes(
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.$client
|
||||
.prepare(
|
||||
"SELECT id FROM host_health_checks WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, host.id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE host_health_checks SET checks = ?, interval_seconds = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(JSON.stringify(checks), interval, now, existing.id);
|
||||
} else {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO host_health_checks (user_id, host_id, checks, interval_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(userId, host.id, JSON.stringify(checks), interval, now, now);
|
||||
}
|
||||
DatabaseSaveTrigger.triggerSave("host_health_checks_updated");
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
@@ -261,10 +230,10 @@ export function registerHealthRoutes(
|
||||
"health_run",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = loadChecks(userId, host.id);
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
if (results.length) {
|
||||
recordHistory(userId, host.id, results);
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
|
||||
@@ -3,9 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { opksshTokens } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createCurrentOpksshTokenRepository } from "../database/repositories/current-opkssh-token-repository.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -649,7 +647,6 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
|
||||
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const expiresAt = new Date();
|
||||
@@ -675,32 +672,17 @@ async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(opksshTokens)
|
||||
.values({
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
email: session.identity.email,
|
||||
sub: session.identity.sub,
|
||||
issuer: session.identity.issuer,
|
||||
audience: session.identity.audience,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [opksshTokens.userId, opksshTokens.hostId],
|
||||
set: {
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
email: session.identity.email,
|
||||
sub: session.identity.sub,
|
||||
issuer: session.identity.issuer,
|
||||
audience: session.identity.audience,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await createCurrentOpksshTokenRepository().upsert({
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
email: session.identity.email,
|
||||
sub: session.identity.sub,
|
||||
issuer: session.identity.issuer,
|
||||
audience: session.identity.audience,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
session.status = "completed";
|
||||
session.ws.send(
|
||||
@@ -733,28 +715,17 @@ export async function getOPKSSHToken(
|
||||
hostId: number,
|
||||
): Promise<{ sshCert: string; privateKey: string } | null> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const token = await db
|
||||
.select()
|
||||
.from(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
const repository = createCurrentOpksshTokenRepository();
|
||||
const tokenData = await repository.findByUserAndHost(userId, hostId);
|
||||
|
||||
if (!token || token.length === 0) {
|
||||
if (!tokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = token[0];
|
||||
const expiresAt = new Date(tokenData.expiresAt);
|
||||
|
||||
if (expiresAt < new Date()) {
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
await repository.deleteByUserAndHost(userId, hostId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -778,12 +749,7 @@ export async function getOPKSSHToken(
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
await repository.updateLastUsed(userId, hostId);
|
||||
|
||||
return {
|
||||
sshCert: decryptedCert,
|
||||
@@ -799,12 +765,10 @@ export async function deleteOPKSSHToken(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function invalidateOPKSSHToken(
|
||||
@@ -813,12 +777,10 @@ export async function invalidateOPKSSHToken(
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
await createCurrentOpksshTokenRepository().deleteByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error(`Failed to invalidate OPKSSH token`, {
|
||||
userId,
|
||||
|
||||
@@ -6,7 +6,7 @@ vi.mock("fs/promises", () => ({
|
||||
access: mockAccess,
|
||||
}));
|
||||
|
||||
import { applyAgentAuth, resolveAgentSocket } from "./terminal-auth-helpers.js";
|
||||
import { resolveAgentSocket } from "./terminal-auth-helpers.js";
|
||||
|
||||
describe("resolveAgentSocket", () => {
|
||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||
@@ -63,25 +63,6 @@ describe("resolveAgentSocket", () => {
|
||||
expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.789" });
|
||||
});
|
||||
|
||||
it("reads agent socket path from serialized terminal config", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
|
||||
const result = await resolveAgentSocket(
|
||||
JSON.stringify({ agentSocketPath: "/tmp/serialized-agent.sock" }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ socketPath: "/tmp/serialized-agent.sock" });
|
||||
});
|
||||
|
||||
it("returns error for invalid serialized terminal config", async () => {
|
||||
const result = await resolveAgentSocket("{");
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "Invalid terminal configuration for SSH agent auth.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when neither SSH_AUTH_SOCK nor explicit path is set", async () => {
|
||||
const result = await resolveAgentSocket({});
|
||||
|
||||
@@ -119,17 +100,4 @@ describe("resolveAgentSocket", () => {
|
||||
});
|
||||
expect(mockAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches an ssh2 agent to a connect config", async () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
const result = await applyAgentAuth(config, {
|
||||
agentSocketPath: "/tmp/ssh-agent.sock",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ socketPath: "/tmp/ssh-agent.sock" });
|
||||
expect(config.agent).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,20 +58,10 @@ export class MemoryAgent extends BaseAgent {
|
||||
}
|
||||
|
||||
export async function resolveAgentSocket(
|
||||
terminalConfig: Record<string, unknown> | string | undefined,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
let parsedConfig: Record<string, unknown> | undefined;
|
||||
if (typeof terminalConfig === "string") {
|
||||
try {
|
||||
parsedConfig = JSON.parse(terminalConfig) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { error: "Invalid terminal configuration for SSH agent auth." };
|
||||
}
|
||||
} else {
|
||||
parsedConfig = terminalConfig;
|
||||
}
|
||||
const explicit = (
|
||||
parsedConfig?.agentSocketPath as string | undefined
|
||||
terminalConfig?.agentSocketPath as string | undefined
|
||||
)?.trim();
|
||||
const resolved = explicit || process.env.SSH_AUTH_SOCK;
|
||||
|
||||
@@ -97,7 +87,7 @@ export async function resolveAgentSocket(
|
||||
|
||||
export async function applyAgentAuth(
|
||||
connectConfig: Record<string, unknown>,
|
||||
terminalConfig: Record<string, unknown> | string | undefined,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
): Promise<{ socketPath: string } | { error: string }> {
|
||||
const result = await resolveAgentSocket(terminalConfig);
|
||||
if ("error" in result) return result;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import ssh2Pkg, { type Client as SSHClientType } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
@@ -45,17 +42,14 @@ async function resolveJumpHost(
|
||||
hostId,
|
||||
});
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
const resolvedHost = await repository.findHostById(hostId, userId);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
if (!resolvedHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const host = resolvedHost as Record<string, unknown>;
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -87,22 +81,12 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
const credential = (await repository.findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
ownerId,
|
||||
);
|
||||
)) as Record<string, unknown> | null;
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub all external imports before loading the module under test
|
||||
const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]);
|
||||
const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning });
|
||||
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
|
||||
const mockCreate = vi.fn().mockResolvedValue({ id: 1 });
|
||||
const mockUpdateEnded = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
$client: {
|
||||
prepare: () => ({ get: () => undefined }),
|
||||
},
|
||||
insert: mockInsert,
|
||||
}),
|
||||
getDb: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../database/db/schema.js", () => ({
|
||||
sessionRecordings: {},
|
||||
}));
|
||||
vi.mock(
|
||||
"../database/repositories/current-session-recording-repository.js",
|
||||
() => ({
|
||||
createCurrentSessionRecordingRepository: () => ({
|
||||
create: mockCreate,
|
||||
updateEnded: mockUpdateEnded,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("../utils/logger.js", () => ({
|
||||
sshLogger: {
|
||||
@@ -60,9 +60,8 @@ describe("TerminalSessionManager - session logging", () => {
|
||||
// Re-apply resolved values after clearAllMocks
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockReturning.mockResolvedValue([{ id: 1 }]);
|
||||
mockInsertValues.mockReturnValue({ returning: mockReturning });
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("createSession stores sessionLoggingEnabled=true by default", () => {
|
||||
@@ -117,8 +116,7 @@ describe("TerminalSessionManager - session logging", () => {
|
||||
sessionManager.destroySession(id);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(mockWriteFile).toHaveBeenCalledOnce();
|
||||
expect(mockInsert).toHaveBeenCalledOnce();
|
||||
expect(mockInsertValues).toHaveBeenCalledOnce();
|
||||
expect(mockCreate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not write log file when buffer is empty", async () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessionRecordings } from "../database/db/schema.js";
|
||||
import { getCurrentSettingValue } from "../database/repositories/current-settings-repository.js";
|
||||
import { createCurrentSessionRecordingRepository } from "../database/repositories/current-session-recording-repository.js";
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
||||
@@ -428,27 +428,24 @@ class TerminalSessionManager {
|
||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const repo = createCurrentSessionRecordingRepository();
|
||||
if (session.recordingId == null) {
|
||||
const rows = await db
|
||||
.insert(sessionRecordings)
|
||||
.values({
|
||||
hostId: session.hostId,
|
||||
userId: session.userId,
|
||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
})
|
||||
.returning({ id: sessionRecordings.id });
|
||||
session.recordingId = rows[0]?.id ?? null;
|
||||
const created = await repo.create({
|
||||
hostId: session.hostId,
|
||||
userId: session.userId,
|
||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
});
|
||||
session.recordingId = created.id;
|
||||
} else {
|
||||
await db
|
||||
.update(sessionRecordings)
|
||||
.set({ endedAt: new Date(endedAt).toISOString(), duration })
|
||||
.where(eq(sessionRecordings.id, session.recordingId));
|
||||
await repo.updateEnded(session.recordingId, {
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to insert session recording row", {
|
||||
@@ -550,14 +547,9 @@ class TerminalSessionManager {
|
||||
|
||||
private getTimeoutMs(): number {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
if (row) {
|
||||
const minutes = parseInt(row.value, 10);
|
||||
const value = getCurrentSettingValue("terminal_session_timeout_minutes");
|
||||
if (value) {
|
||||
const minutes = parseInt(value, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
return minutes * 60_000;
|
||||
}
|
||||
|
||||
+40
-109
@@ -7,12 +7,9 @@ import ssh2Pkg, {
|
||||
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
||||
import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { logAudit } from "../utils/audit-logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import {
|
||||
@@ -30,9 +27,9 @@ import {
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
import {
|
||||
applyAgentAuth,
|
||||
MemoryAgent,
|
||||
performPortKnocking,
|
||||
resolveAgentSocket,
|
||||
} from "./terminal-auth-helpers.js";
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
@@ -56,8 +53,6 @@ interface ConnectToHostData {
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
passwordFallbackOnly?: boolean;
|
||||
passwordFallbackAttempted?: boolean;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
@@ -76,6 +71,8 @@ interface ConnectToHostData {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
enableSessionLogging?: boolean;
|
||||
/** When true, ignore key material and force password auth (fallback path). */
|
||||
passwordFallbackOnly?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
@@ -786,15 +783,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const opksshData = data as { hostId: number };
|
||||
try {
|
||||
const { startOPKSSHAuth } = await import("./opkssh-auth.js");
|
||||
const { getRequestBaseUrl } =
|
||||
const { getRequestOrigin } =
|
||||
await import("../utils/request-origin.js");
|
||||
const db = getDb();
|
||||
const hostRow = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, opksshData.hostId))
|
||||
.limit(1);
|
||||
if (!hostRow || hostRow.length === 0) {
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
opksshData.hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host) {
|
||||
sshLogger.error(
|
||||
`Host ${opksshData.hostId} not found for OPKSSH auth`,
|
||||
{
|
||||
@@ -812,8 +808,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
break;
|
||||
}
|
||||
const hostname = hostRow[0].name || hostRow[0].ip;
|
||||
const requestOrigin = getRequestBaseUrl(req);
|
||||
const hostname = host.name || host.ip;
|
||||
const requestOrigin = getRequestOrigin(req);
|
||||
await startOPKSSHAuth(
|
||||
userId,
|
||||
opksshData.hostId,
|
||||
@@ -904,9 +900,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
try {
|
||||
const { loadVaultProfileForHost, startVaultAuth } =
|
||||
await import("./vault-oidc-auth.js");
|
||||
const { getRequestBaseUrl } =
|
||||
const { getRequestOrigin } =
|
||||
await import("../utils/request-origin.js");
|
||||
const profile = await loadVaultProfileForHost(vaultData.hostId);
|
||||
const profile = await loadVaultProfileForHost(
|
||||
vaultData.hostId,
|
||||
userId,
|
||||
);
|
||||
if (!profile) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -917,7 +916,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
break;
|
||||
}
|
||||
const requestOrigin = getRequestBaseUrl(req);
|
||||
const requestOrigin = getRequestOrigin(req);
|
||||
await startVaultAuth(
|
||||
userId,
|
||||
vaultData.hostId,
|
||||
@@ -1109,6 +1108,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
isConnecting = true;
|
||||
sshConn = new Client();
|
||||
|
||||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||||
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (sshConn && isConnecting && !isConnected) {
|
||||
sshLogger.error("SSH connection timeout", undefined, {
|
||||
@@ -1161,10 +1163,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
)) as unknown as typeof resolvedHostData;
|
||||
|
||||
if (resolvedHostData) {
|
||||
ip = resolvedHostData.ip || ip;
|
||||
port = resolvedHostData.port || port;
|
||||
username = resolvedHostData.username || username;
|
||||
|
||||
if (
|
||||
(!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) &&
|
||||
resolvedHostData.jumpHosts &&
|
||||
@@ -1228,8 +1226,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (id && userId && !password && !key) {
|
||||
try {
|
||||
if (resolvedHostData) {
|
||||
ip = resolvedHostData.ip || ip;
|
||||
port = resolvedHostData.port || port;
|
||||
username = resolvedHostData.username || username;
|
||||
resolvedCredentials = {
|
||||
username,
|
||||
username: resolvedHostData.username || username,
|
||||
password: resolvedHostData.password,
|
||||
key: resolvedHostData.key,
|
||||
keyPassword: keyPassword || resolvedHostData.keyPassword,
|
||||
@@ -1253,8 +1254,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
} else if (credentialId && id && userId) {
|
||||
try {
|
||||
if (resolvedHostData) {
|
||||
ip = resolvedHostData.ip || ip;
|
||||
port = resolvedHostData.port || port;
|
||||
username = resolvedHostData.username || username;
|
||||
resolvedCredentials = {
|
||||
username,
|
||||
username: resolvedHostData.username || username,
|
||||
password: resolvedHostData.password,
|
||||
key: resolvedHostData.key,
|
||||
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
|
||||
@@ -1819,23 +1823,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.id, id),
|
||||
eq(hosts.userId, hostConfig.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
hostConfig.userId!,
|
||||
);
|
||||
const host =
|
||||
await createCurrentHostResolutionRepository().findHostById(
|
||||
id,
|
||||
hostConfig.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hostResults.length > 0 && hostResults[0].name
|
||||
? hostResults[0].name
|
||||
host?.userId === hostConfig.userId && host.name
|
||||
? host.name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
await axios.post(
|
||||
@@ -1884,72 +1880,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyboardInteractiveResponded,
|
||||
});
|
||||
|
||||
const isAuthFailure =
|
||||
err.message.includes("All configured authentication methods failed") ||
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Authentication failed") ||
|
||||
err.message.includes("Permission denied");
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.password &&
|
||||
isAuthFailure &&
|
||||
!hostConfig.passwordFallbackAttempted
|
||||
) {
|
||||
sendLog(
|
||||
"auth",
|
||||
"warning",
|
||||
"SSH key authentication failed; retrying with stored password",
|
||||
);
|
||||
sshLogger.warn("Retrying SSH connection with stored password", {
|
||||
operation: "terminal_key_password_fallback",
|
||||
hostId: id,
|
||||
userId,
|
||||
});
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
currentSessionId = null;
|
||||
}
|
||||
cleanupAuthState(connectionTimeout);
|
||||
sshConn = null;
|
||||
sshStream = null;
|
||||
handleConnectToHost({
|
||||
...data,
|
||||
hostConfig: {
|
||||
...hostConfig,
|
||||
authType: "password",
|
||||
password: resolvedCredentials.password,
|
||||
key: undefined,
|
||||
keyPassword: undefined,
|
||||
keyType: undefined,
|
||||
passwordFallbackOnly: true,
|
||||
passwordFallbackAttempted: true,
|
||||
},
|
||||
}).catch((fallbackError) => {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error
|
||||
? fallbackError.message
|
||||
: "Unknown error";
|
||||
sshLogger.error(
|
||||
"Password fallback connection failed",
|
||||
fallbackError,
|
||||
{
|
||||
operation: "terminal_key_password_fallback_error",
|
||||
hostId: id,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to retry with stored password: " + fallbackMessage,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "opkssh" &&
|
||||
err.message.includes("All configured authentication methods failed")
|
||||
@@ -2608,14 +2538,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "agent") {
|
||||
sendLog("auth", "info", "Using SSH agent authentication");
|
||||
const result = await applyAgentAuth(
|
||||
connectConfig as Record<string, unknown>,
|
||||
const result = await resolveAgentSocket(
|
||||
hostConfig.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
ws.send(JSON.stringify({ type: "error", message: result.error }));
|
||||
return;
|
||||
}
|
||||
const { createAgent } = ssh2Pkg;
|
||||
connectConfig.agent = createAgent(result.socketPath);
|
||||
sendLog(
|
||||
"auth",
|
||||
"info",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { tmuxSessionTags, users } from "../database/db/schema.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { createCurrentTmuxSessionTagRepository } from "../database/repositories/current-tmux-session-tag-repository.js";
|
||||
import { createCurrentUserRepository } from "../database/repositories/current-user-repository.js";
|
||||
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
@@ -287,21 +286,10 @@ async function fetchSessionTags(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const rows = await getDb()
|
||||
.select()
|
||||
.from(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, hostId),
|
||||
),
|
||||
);
|
||||
const bySession = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []);
|
||||
bySession.get(row.sessionName)!.push(row.tag);
|
||||
}
|
||||
return bySession;
|
||||
return createCurrentTmuxSessionTagRepository().listByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
}
|
||||
|
||||
async function collectPaneMetrics(
|
||||
@@ -367,7 +355,7 @@ async function requireHost(
|
||||
res.status(400).json({ error: "Invalid host ID" });
|
||||
return null;
|
||||
}
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
res.status(401).json({ error: "User data is locked" });
|
||||
return null;
|
||||
}
|
||||
@@ -422,12 +410,8 @@ async function auditTmuxAction(
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
let username = userId;
|
||||
try {
|
||||
const actor = await getDb()
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
username = actor[0]?.username ?? userId;
|
||||
const actor = await createCurrentUserRepository().findById(userId);
|
||||
username = actor?.username ?? userId;
|
||||
} catch {
|
||||
// fall back to the raw user id
|
||||
}
|
||||
@@ -639,16 +623,11 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
|
||||
),
|
||||
),
|
||||
);
|
||||
await getDb()
|
||||
.update(tmuxSessionTags)
|
||||
.set({ sessionName: newName })
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, host.id),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
||||
await createCurrentTmuxSessionTagRepository().renameSessionForHost(
|
||||
host.id,
|
||||
sessionName,
|
||||
newName,
|
||||
);
|
||||
sshLogger.info("tmux session renamed", {
|
||||
operation: "tmux_session_rename",
|
||||
hostId: host.id,
|
||||
@@ -691,15 +670,10 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
|
||||
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
|
||||
),
|
||||
);
|
||||
await getDb()
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.hostId, host.id),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
||||
await createCurrentTmuxSessionTagRepository().deleteSessionForHost(
|
||||
host.id,
|
||||
sessionName,
|
||||
);
|
||||
sshLogger.info("tmux session killed", {
|
||||
operation: "tmux_session_kill",
|
||||
hostId: host.id,
|
||||
@@ -936,27 +910,12 @@ app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
|
||||
].slice(0, 20);
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(tmuxSessionTags)
|
||||
.where(
|
||||
and(
|
||||
eq(tmuxSessionTags.userId, userId),
|
||||
eq(tmuxSessionTags.hostId, host.id),
|
||||
eq(tmuxSessionTags.sessionName, sessionName),
|
||||
),
|
||||
);
|
||||
if (cleanTags.length > 0) {
|
||||
await db.insert(tmuxSessionTags).values(
|
||||
cleanTags.map((tag) => ({
|
||||
userId,
|
||||
hostId: host.id,
|
||||
sessionName,
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
|
||||
await createCurrentTmuxSessionTagRepository().replaceForUserHostSession(
|
||||
userId,
|
||||
host.id,
|
||||
sessionName,
|
||||
cleanTags,
|
||||
);
|
||||
res.json({ sessionName, tags: cleanTags });
|
||||
} catch (err) {
|
||||
sshLogger.error(
|
||||
|
||||
@@ -12,9 +12,7 @@ import { WebSocketServer } from "ws";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { ChildProcess } from "child_process";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConfig,
|
||||
@@ -26,7 +24,6 @@ import { CONNECTION_STATES } from "../../types/index.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
import { logAudit } from "../utils/audit-logger.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
@@ -1033,21 +1030,14 @@ async function connectSSHTunnel(
|
||||
|
||||
if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) {
|
||||
try {
|
||||
const userDataKey = DataCrypto.getUserDataKey(
|
||||
tunnelConfig.endpointUserId,
|
||||
);
|
||||
if (userDataKey) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.endpointUserId,
|
||||
);
|
||||
if (DataCrypto.getUserDataKey(tunnelConfig.endpointUserId) !== null) {
|
||||
const credential =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
tunnelConfig.endpointCredentialId,
|
||||
tunnelConfig.endpointUserId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
if (credential) {
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
// ephemeral key, cache the cert, and notify the browser to reconnect.
|
||||
|
||||
import { WebSocket } from "ws";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, vaultProfiles } from "../database/db/schema.js";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/current-host-resolution-repository.js";
|
||||
import { createCurrentVaultProfileRepository } from "../database/repositories/current-vault-profile-repository.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import {
|
||||
type VaultProfileConfig,
|
||||
@@ -61,23 +60,20 @@ function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
|
||||
/** Load the Vault profile referenced by a host, or null if not configured. */
|
||||
export async function loadVaultProfileForHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<VaultProfileConfig | null> {
|
||||
const db = getDb();
|
||||
const hostRows = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, hostId))
|
||||
.limit(1);
|
||||
if (!hostRows.length || hostRows[0].vaultProfileId == null) return null;
|
||||
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (!host || host.vaultProfileId == null) return null;
|
||||
|
||||
const profileRows = await db
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, hostRows[0].vaultProfileId as number))
|
||||
.limit(1);
|
||||
if (!profileRows.length) return null;
|
||||
const profile = await createCurrentVaultProfileRepository().findById(
|
||||
host.vaultProfileId as number,
|
||||
);
|
||||
if (!profile) return null;
|
||||
|
||||
return rowToProfileConfig(profileRows[0] as Record<string, unknown>);
|
||||
return rowToProfileConfig(profile as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and
|
||||
// is re-exported here so callers have a single import surface.
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { vaultTokens } from "../database/db/schema.js";
|
||||
import { createCurrentVaultTokenRepository } from "../database/repositories/current-vault-token-repository.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { parseCertValidBefore } from "./vault-signer-core.js";
|
||||
@@ -52,7 +50,6 @@ export async function storeVaultCert(
|
||||
privateKey: string,
|
||||
signedCert: string,
|
||||
): Promise<string> {
|
||||
const db = getDb();
|
||||
const userDataKey = UserCrypto.getInstance().getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data key not found");
|
||||
@@ -77,24 +74,13 @@ export async function storeVaultCert(
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(vaultTokens)
|
||||
.values({
|
||||
userId,
|
||||
profileId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
expiresAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [vaultTokens.userId, vaultTokens.profileId],
|
||||
set: {
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
expiresAt,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await createCurrentVaultTokenRepository().upsert({
|
||||
userId,
|
||||
profileId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return expiresAt;
|
||||
}
|
||||
@@ -107,17 +93,10 @@ export async function getVaultCert(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<{ privateKey: string; sshCert: string } | null> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(vaultTokens)
|
||||
.where(
|
||||
and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)),
|
||||
)
|
||||
.limit(1);
|
||||
const repository = createCurrentVaultTokenRepository();
|
||||
const row = await repository.findByUserAndProfile(userId, profileId);
|
||||
|
||||
if (!rows || rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const expiresMs = new Date(row.expiresAt).getTime();
|
||||
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
|
||||
@@ -144,12 +123,7 @@ export async function getVaultCert(
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.update(vaultTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(
|
||||
and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)),
|
||||
);
|
||||
await repository.updateLastUsed(userId, profileId);
|
||||
|
||||
return { privateKey, sshCert };
|
||||
}
|
||||
@@ -158,10 +132,8 @@ export async function deleteVaultCert(
|
||||
userId: string,
|
||||
profileId: number,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(vaultTokens)
|
||||
.where(
|
||||
and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)),
|
||||
);
|
||||
await createCurrentVaultTokenRepository().deleteByUserAndProfile(
|
||||
userId,
|
||||
profileId,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user