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:
ZacharyZcR
2026-07-16 12:28:10 +08:00
committed by GitHub
parent ed6cda4ea4
commit 1e7c9ea53c
229 changed files with 24327 additions and 8382 deletions
+57 -116
View File
@@ -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,
};
}
+7 -19
View File
@@ -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,
+8 -24
View File
@@ -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");
},
}));
+4 -12
View File
@@ -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);
+8 -28
View File
@@ -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
+123 -248
View File
@@ -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}` });
}
});
},
);
}
+22 -44
View File
@@ -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(
+22 -38
View File
@@ -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(
+14 -9
View File
@@ -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) {
+9 -25
View File
@@ -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", {
+51 -74
View File
@@ -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
View File
@@ -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",
+23 -60
View File
@@ -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) {
+9 -25
View File
@@ -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,
+36 -67
View File
@@ -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(
+25 -63
View File
@@ -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,
+1 -33
View File
@@ -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();
});
});
+3 -13
View File
@@ -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;
+9 -25
View File
@@ -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 () => {
+21 -29
View File
@@ -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
View File
@@ -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",
+25 -66
View File
@@ -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(
+8 -18
View File
@@ -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
+13 -17
View File
@@ -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 -44
View File
@@ -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,
);
}