release-2.5.0 (#994)

* 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: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

* Fix PUID html ownership in Docker entrypoint (#954)

* feat: allow custom tunnel endpoints (#977)

* fix: skip metrics start for non-ssh hosts (#976)

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

* Prioritize host results in command palette search (#969)

* Prevent sidebar host hover layout shift (#970)

* Add terminal font zoom with mouse wheel (#971)

* Add host temperature metrics card (#972)

* Make file downloads reliable in desktop app (#973)

* Add app rail hover expansion setting (#974)

* fix: use correct translation key for nav.close (#964)

* fix(tunnel): skip endpoint credential validation for direct tunnels (#963)

* fix: SSH port connection bug (#975)

* fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948)

* feat: support SSH agent auth across SSH features (#960)

* feat: add Podman container runtime support (#958)

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

* Add external editor support for file manager (#985)

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

* Fix runtime base path for auth callbacks (#982)

* Fix TUI terminal output highlighting (#983)

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

* chore: lint, format, and bump version to 2.5.0

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
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>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
+380
View File
@@ -0,0 +1,380 @@
import { getDb } from "../database/db/index.js";
import { statsLogger } from "../utils/logger.js";
import {
sendNotification,
type AlertPayload,
type NotificationChannel,
} from "../utils/notification-sender.js";
type AlertTriggerType =
| "host_offline"
| "host_online"
| "cpu_threshold"
| "memory_threshold"
| "disk_threshold"
| "health_check_failure"
| "health_check_recovery"
| "user_login";
interface AlertRule {
id: number;
userId: string;
hostId: number | null;
name: string;
enabled: boolean;
triggerType: AlertTriggerType;
thresholdValue: number | null;
thresholdDurationSeconds: number | null;
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;
// "${ruleId}:${hostId}" → lastFiredAt ms
private cooldownMap = new Map<string, number>();
// "${ruleId}:${hostId}" → breachStartTime ms
private breachStartMap = new Map<string, number>();
// hostId → last known status
private lastStatusMap = new Map<number, "online" | "offline">();
// "${hostId}:${checkId}" → last known ok state
private healthCheckStateMap = new Map<string, boolean>();
static getInstance(): AlertEngine {
if (!AlertEngine.instance) {
AlertEngine.instance = new AlertEngine();
}
return AlertEngine.instance;
}
async evaluateMetrics(
hostId: number,
metrics: {
cpu?: { percent: number | null } | null;
memory?: { percent: number | null } | null;
disk?: { percent: number | null } | null;
},
): Promise<void> {
const rules = this.loadRulesForHost(hostId).filter((r) =>
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
r.triggerType,
),
);
if (rules.length === 0) return;
const now = Date.now();
for (const rule of rules) {
let currentValue: number | null | undefined;
if (rule.triggerType === "cpu_threshold")
currentValue = metrics.cpu?.percent;
else if (rule.triggerType === "memory_threshold")
currentValue = metrics.memory?.percent;
else if (rule.triggerType === "disk_threshold")
currentValue = metrics.disk?.percent;
if (currentValue == null || rule.thresholdValue == null) continue;
const key = `${rule.id}:${hostId}`;
const isBreaching = currentValue >= rule.thresholdValue;
if (isBreaching) {
if (!this.breachStartMap.has(key)) {
this.breachStartMap.set(key, now);
}
const breachStart = this.breachStartMap.get(key)!;
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
if (
now - breachStart >= durationMs &&
!this.isCoolingDown(rule.id, hostId)
) {
const hostName = 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}%)`,
severity: currentValue >= 95 ? "critical" : "warning",
});
}
} else {
this.breachStartMap.delete(key);
}
}
}
async evaluateStatus(hostId: number, isOnline: boolean): Promise<void> {
const currentStatus = isOnline ? "online" : "offline";
const lastStatus = this.lastStatusMap.get(hostId);
if (lastStatus === currentStatus) return;
this.lastStatusMap.set(hostId, currentStatus);
if (lastStatus === undefined) return;
const triggerType: AlertTriggerType = isOnline
? "host_online"
: "host_offline";
const rules = this.loadRulesForHost(hostId).filter(
(r) => r.triggerType === triggerType,
);
for (const rule of rules) {
if (!this.isCoolingDown(rule.id, hostId)) {
const hostName = this.getHostName(hostId);
await this.fireAlert(rule, hostId, hostName, {
message: isOnline
? `Host "${hostName}" is back online`
: `Host "${hostName}" is offline`,
severity: isOnline ? "info" : "critical",
});
}
}
}
async evaluateHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): Promise<void> {
const stateKey = `${hostId}:${checkId}`;
const lastOk = this.healthCheckStateMap.get(stateKey);
this.healthCheckStateMap.set(stateKey, ok);
if (lastOk === undefined) return;
let triggerType: AlertTriggerType | null = null;
if (!ok && lastOk) triggerType = "health_check_failure";
else if (ok && !lastOk) triggerType = "health_check_recovery";
if (!triggerType) return;
const rules = 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);
await this.fireAlert(rule, hostId, hostName, {
message: ok
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
: `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`,
severity: ok ? "info" : "warning",
});
}
}
}
async evaluateUserLogin(
hostId: number,
userId: string,
sshUser: string,
fromIp: string,
): Promise<void> {
const rules = 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);
await this.fireAlert(rule, hostId, hostName, {
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
severity: "info",
});
}
}
}
private async fireAlert(
rule: AlertRule,
hostId: number,
hostName: string,
context: {
value?: number;
message: string;
severity: "info" | "warning" | "critical";
},
): Promise<void> {
this.markCooldown(rule.id, hostId);
const payload: AlertPayload = {
hostName,
hostId,
triggerType: rule.triggerType,
value: context.value,
threshold: rule.thresholdValue ?? undefined,
message: context.message,
severity: context.severity,
timestamp: new Date().toISOString(),
ruleId: rule.id,
ruleName: rule.name,
};
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,
);
// 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);
} catch (err) {
statsLogger.warn("Failed to write alert firing", {
operation: "alert_firing_insert_error",
ruleId: rule.id,
error: err instanceof Error ? err.message : String(err),
});
}
const channels = this.loadChannelsForRule(rule.id);
for (const channel of channels) {
sendNotification(channel, payload).catch((err) => {
statsLogger.warn("Failed to send notification", {
operation: "notification_delivery_error",
channelId: channel.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
}
private isCoolingDown(ruleId: number, hostId: number): boolean {
const key = `${ruleId}:${hostId}`;
const lastFired = this.cooldownMap.get(key);
if (!lastFired) return false;
const rule = this.loadRuleById(ruleId);
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
return Date.now() - lastFired < cooldownMs;
}
private markCooldown(ruleId: number, hostId: number): void {
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
}
private loadRulesForHost(hostId: number): 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);
} catch {
return [];
}
}
private loadRulesForHostUser(hostId: number, userId: string): 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);
} catch {
return [];
}
}
private loadRuleById(ruleId: number): 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;
} catch {
return null;
}
}
private loadChannelsForRule(ruleId: number): 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,
}));
} catch {
return [];
}
}
private getHostName(hostId: number): 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}`;
} 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,
};
}
+43
View File
@@ -0,0 +1,43 @@
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getContainerRuntimeConfig(raw: unknown): {
runtime: ContainerRuntime;
} {
if (!raw) {
return { runtime: "docker" };
}
let config: unknown = raw;
if (typeof raw === "string") {
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { runtime: "docker" };
}
}
if (!config || typeof config !== "object") {
return { runtime: "docker" };
}
return {
runtime: normalizeContainerRuntime(
(config as Record<string, unknown>).runtime,
),
};
}
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
): string {
return `${normalizeContainerRuntime(runtime)} ${args}`;
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
+50 -3
View File
@@ -8,6 +8,12 @@ import { getDb } from "../database/db/index.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { systemLogger } from "../utils/logger.js";
import type { SSHHost } from "../../types/index.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
const sshLogger = systemLogger;
@@ -18,6 +24,7 @@ interface SSHSession {
containerId?: string;
shell?: string;
hostId?: number;
containerRuntime?: ContainerRuntime;
}
const activeSessions = new Map<string, SSHSession>();
@@ -37,7 +44,10 @@ async function detectShell(
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
`docker exec ${containerId} which ${shell}`,
containerCommand(
session.containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
@@ -219,6 +229,16 @@ async function createJumpHostChain(
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
jumpHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
client.on(
@@ -427,6 +447,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (resolvedHost.keyPassword) {
config.passphrase = resolvedHost.keyPassword;
}
} else if (resolvedHost.authType === "agent") {
const result = await applyAgentAuth(
config,
resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
ws.send(
JSON.stringify({
type: "error",
message: result.error,
}),
);
return;
}
}
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
@@ -459,12 +495,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
client.connect(config);
});
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
);
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
hostId: resolvedHost.id,
containerRuntime,
};
activeSessions.set(sessionId, sshSession);
@@ -475,7 +516,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
try {
await new Promise<void>((resolve, reject) => {
client.exec(
`docker exec ${containerId} which ${shell}`,
containerCommand(
containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
@@ -518,7 +562,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshSession.shell = shellToUse;
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
const execCommand = containerCommand(
containerRuntime,
`exec -it ${containerId} /bin/${shellToUse}`,
);
sshLogger.info("Attaching to Docker container", {
operation: "docker_attach",
sessionId,
+33 -10
View File
@@ -1,5 +1,9 @@
import type express from "express";
import { logger } from "../utils/logger.js";
import {
containerCommand,
type ContainerRuntime,
} from "./container-runtime.js";
const sshLogger = logger;
@@ -9,6 +13,7 @@ type DockerSession = {
activeOperations: number;
hostId?: number;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
};
type PendingDockerTotpSession = unknown;
@@ -39,6 +44,9 @@ export function registerDockerContainerRoutes(
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
}: DockerContainerRoutesDeps,
): void {
const getRuntime = (session: DockerSession) =>
session.containerRuntime ?? "docker";
/**
* @openapi
* /docker/containers/{sessionId}:
@@ -97,7 +105,10 @@ export function registerDockerContainerRoutes(
const formatStr = session.isWindows
? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`
: `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const command = `docker ps ${allFlag}--format ${formatStr}`;
const command = containerCommand(
getRuntime(session),
`ps ${allFlag}--format ${formatStr}`,
);
const output = await executeDockerCommand(
session,
@@ -190,7 +201,10 @@ export function registerDockerContainerRoutes(
session.activeOperations++;
try {
const command = `docker inspect ${containerId}`;
const command = containerCommand(
getRuntime(session),
`inspect ${containerId}`,
);
const output = await executeDockerCommand(
session,
command,
@@ -295,7 +309,7 @@ export function registerDockerContainerRoutes(
});
await executeDockerCommand(
session,
`docker start ${containerId}`,
containerCommand(getRuntime(session), `start ${containerId}`),
sessionId,
userId,
session.hostId,
@@ -395,7 +409,7 @@ export function registerDockerContainerRoutes(
});
await executeDockerCommand(
session,
`docker stop ${containerId}`,
containerCommand(getRuntime(session), `stop ${containerId}`),
sessionId,
userId,
session.hostId,
@@ -495,7 +509,7 @@ export function registerDockerContainerRoutes(
});
await executeDockerCommand(
session,
`docker restart ${containerId}`,
containerCommand(getRuntime(session), `restart ${containerId}`),
sessionId,
userId,
session.hostId,
@@ -595,7 +609,7 @@ export function registerDockerContainerRoutes(
});
await executeDockerCommand(
session,
`docker pause ${containerId}`,
containerCommand(getRuntime(session), `pause ${containerId}`),
sessionId,
userId,
session.hostId,
@@ -695,7 +709,7 @@ export function registerDockerContainerRoutes(
});
await executeDockerCommand(
session,
`docker unpause ${containerId}`,
containerCommand(getRuntime(session), `unpause ${containerId}`),
sessionId,
userId,
session.hostId,
@@ -801,7 +815,10 @@ export function registerDockerContainerRoutes(
const forceFlag = force ? "-f " : "";
await executeDockerCommand(
session,
`docker rm ${forceFlag}${containerId}`,
containerCommand(
getRuntime(session),
`rm ${forceFlag}${containerId}`,
),
sessionId,
userId,
session.hostId,
@@ -920,7 +937,10 @@ export function registerDockerContainerRoutes(
session.activeOperations++;
try {
let command = `docker logs ${containerId}`;
let command = containerCommand(
getRuntime(session),
`logs ${containerId}`,
);
if (tail && tail > 0) {
command += ` --tail ${Math.floor(tail)}`;
@@ -1035,7 +1055,10 @@ export function registerDockerContainerRoutes(
const statsFormatStr = session.isWindows
? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`
: `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`;
const command = containerCommand(
getRuntime(session),
`stats ${containerId} --no-stream --format ${statsFormatStr}`,
);
const output = await executeDockerCommand(
session,
+91 -41
View File
@@ -19,6 +19,15 @@ import type { SSHHost, ProxyNode } from "../../types/index.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { registerDockerContainerRoutes } from "./docker-container-routes.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
getRuntimeLabel,
type ContainerRuntime,
} from "./container-runtime.js";
const sshLogger = logger;
@@ -45,6 +54,7 @@ interface SSHSession {
hostId?: number;
userId?: string;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
}
interface PendingTOTPSession {
@@ -63,6 +73,7 @@ interface PendingTOTPSession {
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
containerRuntime?: ContainerRuntime;
}
const sshSessions: Record<string, SSHSession> = {};
@@ -133,6 +144,12 @@ interface JumpHostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
[key: string]: unknown;
}
@@ -253,13 +270,17 @@ async function createJumpHostChain(
}
}
const firstHopSocks5Config = getJumpHostSocks5Config(
jumpHostConfigs[0],
socks5Config,
);
let proxySocket: import("net").Socket | null = null;
if (socks5Config?.useSocks5) {
if (firstHopSocks5Config?.useSocks5) {
const firstHop = jumpHostConfigs[0]!;
proxySocket = await createSocks5Connection(
firstHop.ip,
firstHop.port || 22,
socks5Config,
firstHopSocks5Config,
);
}
@@ -278,7 +299,8 @@ async function createJumpHostChain(
true,
);
const connected = await new Promise<boolean>((resolve) => {
// eslint-disable-next-line no-async-promise-executor
const connected = await new Promise<boolean>(async (resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
@@ -371,6 +393,16 @@ async function createJumpHostChain(
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
jumpClient.on(
@@ -700,6 +732,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
host.terminalConfig = undefined;
}
}
const { runtime: containerRuntime } = getContainerRuntimeConfig(
host.dockerConfig,
);
if (!host.enableDocker) {
sshLogger.warn("Docker not enabled for host", {
@@ -923,37 +958,16 @@ app.post("/docker/ssh/connect", async (req, res) => {
resolvedCredentials.sshKey
) {
try {
if (
!resolvedCredentials.sshKey.includes("-----BEGIN") ||
!resolvedCredentials.sshKey.includes("-----END")
) {
sshLogger.error("Invalid SSH key format", {
operation: "docker_connect",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
"Invalid SSH private key format",
),
);
return res.status(400).json({
error: "Invalid private key format",
connectionLogs,
});
}
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
config.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.sshKey,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid private key format";
sshLogger.error("SSH key processing error", error, {
operation: "docker_connect",
sessionId,
@@ -963,11 +977,11 @@ app.post("/docker/ssh/connect", async (req, res) => {
createConnectionLog(
"error",
"docker_auth",
"SSH key processing error",
`SSH key processing error: ${message}`,
),
);
return res.status(400).json({
error: "SSH key format error: Invalid private key format",
error: `SSH key format error: ${message}`,
connectionLogs,
});
}
@@ -988,6 +1002,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
error: "SSH key authentication requested but no key provided",
connectionLogs,
});
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
connectionLogs.push(
createConnectionLog("error", "docker_auth", result.error),
);
return res.status(400).json({ error: result.error, connectionLogs });
}
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Using SSH agent authentication",
),
);
}
let responseSent = false;
@@ -1015,6 +1047,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH key"),
);
} else if (resolvedCredentials.authType === "agent") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH agent"),
);
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
@@ -1047,6 +1083,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
activeOperations: 0,
hostId,
userId,
containerRuntime,
};
sshSessions[sessionId] = session;
@@ -1244,6 +1281,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
isWarpgate: true,
containerRuntime,
};
connectionLogs.push(
@@ -1310,6 +1348,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
connectionLogs.push(
@@ -1400,6 +1439,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
res.json({
@@ -1753,6 +1793,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
activeOperations: 0,
hostId: session.hostId,
userId,
containerRuntime: session.containerRuntime,
};
scheduleSessionCleanup(sessionId);
@@ -1939,6 +1980,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
activeOperations: 0,
hostId: session.hostId,
userId,
containerRuntime: session.containerRuntime,
};
scheduleSessionCleanup(sessionId);
@@ -2154,20 +2196,24 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
try {
try {
const runtime = session.containerRuntime ?? "docker";
const runtimeLabel = getRuntimeLabel(runtime);
const versionOutput = await executeDockerCommand(
session,
"docker --version",
containerCommand(runtime, "--version"),
sessionId,
userId,
session.hostId,
);
const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/);
const versionMatch = versionOutput.match(
/(?:Docker|podman) version ([^\s,]+)/i,
);
const version = versionMatch ? versionMatch[1] : "unknown";
try {
await executeDockerCommand(
session,
"docker ps",
containerCommand(runtime, "ps"),
sessionId,
userId,
session.hostId,
@@ -2177,6 +2223,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
return res.json({
available: true,
version,
runtime,
});
} catch (daemonError) {
session.activeOperations--;
@@ -2186,18 +2233,18 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
if (errorMsg.includes("Cannot connect to the Docker daemon")) {
return res.json({
available: false,
error:
"Docker daemon is not running. Start it with: sudo systemctl start docker",
error: `${runtimeLabel} daemon is not running or accessible`,
code: "DAEMON_NOT_RUNNING",
runtime,
});
}
if (errorMsg.includes("permission denied")) {
return res.json({
available: false,
error:
"Permission denied. Add your user to the docker group: sudo usermod -aG docker $USER",
error: `Permission denied accessing ${runtimeLabel}`,
code: "PERMISSION_DENIED",
runtime,
});
}
@@ -2205,15 +2252,18 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
available: false,
error: errorMsg,
code: "DOCKER_ERROR",
runtime,
});
}
} catch {
session.activeOperations--;
const runtime = session.containerRuntime ?? "docker";
const runtimeLabel = getRuntimeLabel(runtime);
return res.json({
available: false,
error:
"Docker is not installed on this host. Please install Docker to use this feature.",
error: `${runtimeLabel} is not installed on this host.`,
code: "NOT_INSTALLED",
runtime,
});
}
} catch (error) {
@@ -1507,4 +1507,269 @@ export function registerFileContentRoutes(
req.pipe(bb);
},
);
/**
* @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.
* 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}` });
}
});
},
);
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}` });
}
});
req.pipe(bb);
},
);
}
@@ -10,6 +10,7 @@ import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./file-manager-utils.js";
type FileListingRoutesDeps = {
@@ -111,6 +112,7 @@ export function registerFileListingRoutes(
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
@@ -132,6 +134,7 @@ export function registerFileListingRoutes(
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
modifiedTimestamp: attrs.mtime,
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
@@ -306,6 +309,7 @@ export function registerFileListingRoutes(
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
@@ -392,6 +396,7 @@ export function registerFileListingRoutes(
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
@@ -435,6 +440,7 @@ export function registerFileListingRoutes(
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
@@ -5,6 +5,7 @@ import {
formatMtime,
getMimeType,
detectBinary,
parseLsDateToTimestamp,
} from "./file-manager-utils.js";
describe("isExecutableFile", () => {
@@ -84,6 +85,50 @@ describe("getMimeType", () => {
});
});
describe("parseLsDateToTimestamp", () => {
it("parses a year-format date string", () => {
const ts = parseLsDateToTimestamp("Dec 12 2025");
const d = new Date(ts * 1000);
expect(d.getFullYear()).toBe(2025);
expect(d.getMonth()).toBe(11);
expect(d.getDate()).toBe(12);
});
it("parses a time-format date string for a recent file", () => {
const now = new Date();
const month = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
][now.getMonth()];
const day = now.getDate();
const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`);
const d = new Date(ts * 1000);
expect(d.getHours()).toBe(10);
expect(d.getMinutes()).toBe(30);
});
it("returns 0 for an empty or invalid string", () => {
expect(parseLsDateToTimestamp("")).toBe(0);
expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0);
});
it("produces ascending order for older vs newer dates", () => {
const older = parseLsDateToTimestamp("Jan 1 2020");
const newer = parseLsDateToTimestamp("Dec 31 2024");
expect(older).toBeLessThan(newer);
});
});
describe("detectBinary", () => {
it("returns false for empty buffers", () => {
expect(detectBinary(Buffer.from([]))).toBe(false);
+37
View File
@@ -56,6 +56,43 @@ export function modeToPermissions(mode: number): string {
return prefix + perms;
}
export function parseLsDateToTimestamp(dateStr: string): number {
const parts = dateStr.trim().split(/\s+/);
if (parts.length < 3) return 0;
const [month, day, timeOrYear] = parts;
const monthIndex = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
].indexOf(month);
if (monthIndex === -1) return 0;
const dayNum = parseInt(day, 10);
const now = new Date();
if (timeOrYear.includes(":")) {
const [hours, minutes] = timeOrYear.split(":").map(Number);
let year = now.getFullYear();
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
if (candidate > now) year -= 1;
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
}
const year = parseInt(timeOrYear, 10);
if (isNaN(year)) return 0;
return new Date(year, monthIndex, dayNum).getTime() / 1000;
}
export function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
+36 -13
View File
@@ -33,6 +33,7 @@ import {
import { registerFileContentRoutes } from "./file-manager-content-routes.js";
import { createConnectionLog } from "./file-manager-log.js";
import { createJumpHostChain } from "./jump-host-chain.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import {
ChannelOpenSerializer,
execChannel,
@@ -44,6 +45,7 @@ import { registerFileListingRoutes } from "./file-manager-list-routes.js";
import { registerFileOperationRoutes } from "./file-manager-operation-routes.js";
import { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
import { registerFileActionRoutes } from "./file-manager-action-routes.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
const app = express();
@@ -222,6 +224,14 @@ async function buildDedicatedTransferConnectConfig(
token,
username,
);
} else if (authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else if (authType !== "none" && authType !== "tailscale") {
throw new Error(`Unsupported auth type for transfer: ${authType}`);
}
@@ -727,6 +737,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedIp = ip;
let resolvedPort = port;
let resolvedUsername = username;
let resolvedTerminalConfig: Record<string, unknown> | undefined;
let resolvedJumpHosts = jumpHosts;
let resolvedScpLegacy = false;
let resolvedUseSocks5 = useSocks5;
@@ -750,6 +761,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined;
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
@@ -806,6 +820,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined;
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
@@ -929,19 +946,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
resolvedCredentials.sshKey.trim()
) {
try {
if (
!resolvedCredentials.sshKey.includes("-----BEGIN") ||
!resolvedCredentials.sshKey.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
config.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.sshKey,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword)
config.passphrase = resolvedCredentials.keyPassword;
@@ -1044,6 +1052,21 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
connectionLogs,
});
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(config, resolvedTerminalConfig);
if ("error" in result) {
connectionLogs.push(
createConnectionLog("error", "sftp_auth", result.error),
);
return res.status(400).json({ error: result.error, connectionLogs });
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Using SSH agent authentication",
),
);
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale" ||
@@ -19,6 +19,7 @@ describe("supportsMetrics", () => {
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
@@ -0,0 +1,125 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { getDb } from "../database/db/index.js";
import { statsLogger } from "../utils/logger.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "write" | "execute" | "delete" | "share",
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerHostMetricsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
): void {
/**
* @openapi
* /metrics/history/{id}:
* get:
* summary: Get historical metrics for a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of metric history rows.
* 403:
* description: Access denied.
* 404:
* description: Host not found or no access.
*/
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "read");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
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`,
)
.all(hostId, toSqlite(fromTs), toSqlite(toTs));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch metrics history", {
operation: "metrics_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
}
+26 -3
View File
@@ -10,6 +10,8 @@ import {
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
interface JumpHostConfig {
id: number;
@@ -22,6 +24,12 @@ interface JumpHostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
[key: string]: unknown;
}
@@ -145,13 +153,17 @@ export async function createJumpHostChain(
}
}
const firstHopSocks5Config = getJumpHostSocks5Config(
jumpHostConfigs[0],
socks5Config,
);
let proxySocket: import("net").Socket | null = null;
if (socks5Config?.useSocks5) {
if (firstHopSocks5Config?.useSocks5) {
const firstHop = jumpHostConfigs[0]!;
proxySocket = await createSocks5Connection(
firstHop.ip,
firstHop.port || 22,
socks5Config,
firstHopSocks5Config,
);
}
@@ -170,7 +182,8 @@ export async function createJumpHostChain(
true,
);
const connected = await new Promise<boolean>((resolve) => {
// eslint-disable-next-line no-async-promise-executor
const connected = await new Promise<boolean>(async (resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
@@ -261,6 +274,16 @@ export async function createJumpHostChain(
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
jumpClient.on(
@@ -37,7 +37,7 @@ describe("deriveEnabledWidgets", () => {
]);
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS);
expect(out.length).toBe(10);
expect(out.length).toBe(METRIC_CARD_IDS.length);
});
it("empty slots -> empty widgets", () => {
@@ -186,4 +186,94 @@ export function registerHostMetricsSettingsRoutes(
});
}
});
/**
* @openapi
* /global-settings/history:
* get:
* summary: Get metrics history retention setting
* tags:
* - Host Metrics
* responses:
* 200:
* description: Retention setting in days.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings/history", requireAdmin, (_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;
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
} catch (error) {
statsLogger.error("Failed to fetch history retention setting", {
operation: "history_retention_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch history retention setting" });
}
});
/**
* @openapi
* /global-settings/history:
* post:
* summary: Update metrics history retention setting
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* metricsHistoryRetentionDays:
* type: integer
* minimum: 1
* maximum: 90
* responses:
* 200:
* description: Setting saved.
* 400:
* description: Invalid value.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings/history", requireAdmin, (req, res) => {
const { metricsHistoryRetentionDays } = req.body;
if (
typeof metricsHistoryRetentionDays !== "number" ||
metricsHistoryRetentionDays < 1 ||
metricsHistoryRetentionDays > 90
) {
return res.status(400).json({
error: "metricsHistoryRetentionDays must be between 1 and 90",
});
}
try {
const db = getDb();
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('metrics_history_retention_days', ?)",
)
.run(String(Math.floor(metricsHistoryRetentionDays)));
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save history retention setting", {
operation: "history_retention_save_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to save history retention setting" });
}
});
}
+170 -15
View File
@@ -24,15 +24,19 @@ 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,
} from "../utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
import { registerHostMetricsSettingsRoutes } from "./host-metrics-settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./host-metrics-viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences-routes.js";
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
import { AlertEngine } from "./alert-engine.js";
import { registerManagerRoutes } from "./managers/index.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
@@ -42,6 +46,7 @@ import {
isTcpPingEnabled,
supportsMetrics,
} from "./host-metrics-helpers.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import {
cleanupMetricsSession,
getSessionKey,
@@ -129,6 +134,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
"processes",
"ports",
"firewall",
"temperature",
],
statusCheckEnabled: true,
statusCheckInterval: 60,
@@ -294,6 +300,8 @@ class PollingManager {
viewerUserId,
};
this.pollingConfigs.set(host.id, config);
if (isTcpPingEnabled(statsConfig)) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
@@ -335,8 +343,6 @@ class PollingManager {
} else {
this.metricsStore.delete(host.id);
}
this.pollingConfigs.set(host.id, config);
}
private async pollHostStatus(
@@ -373,12 +379,18 @@ class PollingManager {
lastChecked: new Date().toISOString(),
};
this.statusStore.set(refreshedHost.id, statusEntry);
AlertEngine.getInstance()
.evaluateStatus(refreshedHost.id, isOnline)
.catch(() => {});
} catch {
const statusEntry: StatusEntry = {
status: "offline",
lastChecked: new Date().toISOString(),
};
this.statusStore.set(refreshedHost.id, statusEntry);
AlertEngine.getInstance()
.evaluateStatus(refreshedHost.id, false)
.catch(() => {});
}
}
@@ -420,6 +432,10 @@ class PollingManager {
data: metrics,
timestamp: Date.now(),
});
this.insertMetricsHistory(refreshedHost.id, metrics);
AlertEngine.getInstance()
.evaluateMetrics(refreshedHost.id, metrics)
.catch(() => {});
pollingBackoff.reset(refreshedHost.id);
authFailureTracker.reset(refreshedHost.id);
} catch (error) {
@@ -458,6 +474,68 @@ 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;
return isNaN(days) || days < 1 ? 7 : Math.min(days, 90);
} catch {
return 7;
}
}
private insertMetricsHistory(
hostId: number,
metrics: {
cpu: { percent: number | null };
memory: { percent: number | null };
disk: { percent: number | null };
network: {
interfaces: Array<{ rxBytes: string | null; txBytes: string | null }>;
};
},
): 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 retentionDays = this.getRetentionDays();
db.$client
.prepare(
`DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)`,
)
.run(hostId, `-${retentionDays} days`);
} catch (err) {
statsLogger.warn("Failed to write metrics history", {
operation: "insert_metrics_history",
hostId,
error: err instanceof Error ? err.message : String(err),
});
}
}
stopPollingForHost(hostId: number, clearData = true): void {
const config = this.pollingConfigs.get(hostId);
if (config) {
@@ -767,6 +845,15 @@ 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,
@@ -1017,18 +1104,9 @@ async function buildSshConfig(
}
try {
if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) {
throw new Error("Invalid private key format");
}
const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as Record<string, unknown>).privateKey = Buffer.from(
cleanKey,
"utf8",
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
host.key,
host.keyPassword,
);
if (host.keyPassword) {
@@ -1068,6 +1146,14 @@ 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}`,
@@ -1154,6 +1240,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
return new Promise<Client>((resolve, reject) => {
const timeout = setTimeout(() => {
client.end();
jumpClient?.end();
reject(new Error("SSH connection timeout"));
}, 30000);
@@ -1162,8 +1249,13 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
resolve(client);
});
client.on("close", () => {
jumpClient?.end();
});
client.on("error", (err) => {
clearTimeout(timeout);
jumpClient?.end();
reject(err);
});
@@ -1301,6 +1393,14 @@ 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");
@@ -1331,6 +1431,7 @@ 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: [],
@@ -1402,6 +1503,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
uptime,
processes,
system,
temperature,
login_stats,
ports,
firewall,
@@ -1880,6 +1982,20 @@ 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",
@@ -1925,7 +2041,11 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
"Using existing metrics session",
),
);
return res.json({ success: true, connectionLogs });
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 });
}
const config = await buildSshConfig(host);
@@ -2542,6 +2662,12 @@ registerHostMetricsPreferencesRoutes(app, {
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
});
registerHostMetricsHistoryRoutes(app, {
validateHostId,
canAccessHost: async (userId, hostId, level) =>
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
});
registerManagerRoutes(app, {
validateHostId,
runOnHost: async (hostId, userId, level, fn) => {
@@ -2570,6 +2696,35 @@ registerManagerRoutes(app, {
},
});
// Internal endpoint — only accepts calls from localhost.
// Used by the main backend to notify the metrics service of SSH login events.
app.post("/internal/login-alert", async (req, res) => {
const remoteIp = req.socket.remoteAddress;
if (
remoteIp !== "127.0.0.1" &&
remoteIp !== "::1" &&
remoteIp !== "::ffff:127.0.0.1"
) {
return res.status(403).json({ error: "Forbidden" });
}
const systemCrypto = (await import("../utils/system-crypto.js")).SystemCrypto;
const expectedToken = await systemCrypto.getInstance().getInternalAuthToken();
const token = req.headers["x-internal-auth"];
if (!token || token !== expectedToken) {
return res.status(403).json({ error: "Forbidden" });
}
const { hostId, userId, sshUser, fromIp } = req.body as {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
};
AlertEngine.getInstance()
.evaluateUserLogin(hostId, userId, sshUser, fromIp)
.catch(() => {});
res.json({ ok: true });
});
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();
+23 -1
View File
@@ -1,5 +1,5 @@
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.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 { logger } from "../utils/logger.js";
@@ -219,6 +219,28 @@ export async function resolveHostById(
userId,
);
// Resolve a Vault SSH signer profile (shared settings, no secrets). The
// 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];
host.authType = "vault";
}
} catch (e) {
sshLogger.warn("Failed to resolve vault profile for host", {
operation: "host_resolver_vault_profile",
hostId,
error: e instanceof Error ? e.message : "Unknown",
});
}
}
return host as unknown as SSHHost;
}
+26 -3
View File
@@ -10,6 +10,8 @@ import {
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";
type JumpHostConfig = {
id: number;
@@ -22,6 +24,12 @@ type JumpHostConfig = {
keyType?: string;
authType?: string;
credentialId?: number;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
[key: string]: unknown;
};
@@ -145,13 +153,17 @@ export async function createJumpHostChain(
}
}
const firstHopSocks5Config = getJumpHostSocks5Config(
jumpHostConfigs[0],
socks5Config,
);
let proxySocket: import("net").Socket | null = null;
if (socks5Config?.useSocks5) {
if (firstHopSocks5Config?.useSocks5) {
const firstHop = jumpHostConfigs[0]!;
proxySocket = await createSocks5Connection(
firstHop.ip,
firstHop.port || 22,
socks5Config,
firstHopSocks5Config,
);
}
@@ -170,7 +182,8 @@ export async function createJumpHostChain(
true,
);
const connected = await new Promise<boolean>((resolve) => {
// eslint-disable-next-line no-async-promise-executor
const connected = await new Promise<boolean>(async (resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
@@ -261,6 +274,16 @@ export async function createJumpHostChain(
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
jumpClient.on(
+51
View File
@@ -0,0 +1,51 @@
import type { ProxyNode } from "../../types/index.js";
import type { SOCKS5Config } from "../utils/socks5-helper.js";
type JumpHostProxyConfig = {
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: ProxyNode[] | string | null;
};
function parseProxyChain(value: JumpHostProxyConfig["socks5ProxyChain"]) {
if (Array.isArray(value)) {
return value;
}
if (typeof value !== "string" || value.length === 0) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as ProxyNode[]) : [];
} catch {
return [];
}
}
export function getJumpHostSocks5Config(
firstHop: JumpHostProxyConfig | null | undefined,
fallbackConfig?: SOCKS5Config | null,
): SOCKS5Config | null {
if (!firstHop?.useSocks5) {
return fallbackConfig ?? null;
}
const socks5ProxyChain = parseProxyChain(firstHop.socks5ProxyChain);
if (!firstHop.socks5Host && socks5ProxyChain.length === 0) {
return fallbackConfig ?? null;
}
return {
useSocks5: true,
socks5Host: firstHop.socks5Host ?? undefined,
socks5Port: firstHop.socks5Port ?? undefined,
socks5Username: firstHop.socks5Username ?? undefined,
socks5Password: firstHop.socks5Password ?? undefined,
socks5ProxyChain,
};
}
+29 -2
View File
@@ -7,6 +7,7 @@ import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isValidPort } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { AlertEngine } from "../alert-engine.js";
export interface HealthCheck {
id: string;
@@ -178,7 +179,20 @@ export function registerHealthRoutes(
const userId = (req as AuthenticatedRequest).userId;
const checks = loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) recordHistory(userId, host.id, results);
if (results.length) {
recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
}
}
const history = getDb()
.$client.prepare(
@@ -249,7 +263,20 @@ export function registerHealthRoutes(
const userId = (req as AuthenticatedRequest).userId;
const checks = loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) recordHistory(userId, host.id, results);
if (results.length) {
recordHistory(userId, host.id, results);
for (const r of results) {
AlertEngine.getInstance()
.evaluateHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
)
.catch(() => {});
}
}
return { results };
},
),
+4
View File
@@ -12,6 +12,8 @@ import { registerFirewallRoutes } from "./firewall.js";
import { registerUserRoutes } from "./users.js";
import { registerHealthRoutes } from "./health.js";
import { registerLogRoutes } from "./logs.js";
import { registerWireGuardRoutes } from "./wireguard.js";
import { registerTailscaleRoutes } from "./tailscale.js";
/**
* Registers every Host Metrics manager route under the `/host-metrics/managers`
@@ -58,4 +60,6 @@ export function registerManagerRoutes(
registerUserRoutes(app, deps);
registerHealthRoutes(app, deps);
registerLogRoutes(app, deps);
registerWireGuardRoutes(app, deps);
registerTailscaleRoutes(app, deps);
}
+177
View File
@@ -0,0 +1,177 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { isValidTailscaleAction } from "./validation.js";
export interface TailscalePeer {
hostname: string;
tailscaleIPs: string[];
online: boolean;
isExitNode: boolean;
}
export interface TailscaleData {
installed: boolean;
running: boolean;
tailscaleIPs: string[];
hostname: string | null;
peers: TailscalePeer[];
exitNodeInUse: boolean;
}
const PROBE_CMD = [
"command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0",
"tailscale status --json 2>/dev/null",
].join("; ");
export function parseTailscaleData(output: string): TailscaleData {
const notInstalled: TailscaleData = {
installed: false,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
if (output.includes("ts_installed=0")) return notInstalled;
// Strip the installation probe line to get the raw JSON
const lines = output.split("\n");
const jsonLines = lines.filter(
(l) => !l.startsWith("ts_installed=") && l.trim() !== "",
);
const jsonStr = jsonLines.join("\n");
try {
const parsed = JSON.parse(jsonStr) as {
BackendState?: string;
Self?: { HostName?: string; TailscaleIPs?: string[] };
Peer?: Record<
string,
{
HostName?: string;
TailscaleIPs?: string[];
Online?: boolean;
ExitNode?: boolean;
}
>;
CurrentExitNode?: string;
};
const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map(
(p) => ({
hostname: p.HostName ?? "",
tailscaleIPs: p.TailscaleIPs ?? [],
online: p.Online ?? false,
isExitNode: p.ExitNode ?? false,
}),
);
return {
installed: true,
running: parsed.BackendState === "Running",
tailscaleIPs: parsed.Self?.TailscaleIPs ?? [],
hostname: parsed.Self?.HostName ?? null,
peers,
exitNodeInUse:
typeof parsed.CurrentExitNode === "string" &&
parsed.CurrentExitNode !== "",
};
} catch {
return {
installed: true,
running: false,
tailscaleIPs: [],
hostname: null,
peers: [],
exitNodeInUse: false,
};
}
}
export function registerTailscaleRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/tailscale/{id}:
* get:
* summary: Get Tailscale status and IPs
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Tailscale installation status, running state, IPs, and peer count.
*/
app.get(
"/host-metrics/managers/tailscale/:id",
validateHostId,
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
return parseTailscaleData(stdout);
}),
);
/**
* @openapi
* /host-metrics/managers/tailscale/{id}/action:
* post:
* summary: Connect or disconnect Tailscale
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
app.post(
"/host-metrics/managers/tailscale/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"tailscale_action",
async (client, host, req) => {
const { action } = req.body as { action: unknown };
if (!isValidTailscaleAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await execElevated(
client,
`tailscale ${action}`,
host.sudoPassword,
{ forceSudo: false, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
};
},
),
);
}
+17
View File
@@ -93,6 +93,23 @@ export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
+223
View File
@@ -0,0 +1,223 @@
import type { Express } from "express";
import { execElevated } from "./exec-elevated.js";
import { managerHandler } from "./route-helpers.js";
import { ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "./validation.js";
export interface WireGuardPeer {
publicKey: string;
endpoint: string | null;
allowedIPs: string[];
latestHandshake: number | null;
rxBytes: number;
txBytes: number;
}
export interface WireGuardInterface {
name: string;
publicKey: string | null;
listenPort: number | null;
up: boolean;
peers: WireGuardPeer[];
}
export interface WireGuardData {
installed: boolean;
interfaces: WireGuardInterface[];
}
const PROBE_CMD = [
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
"ip link show type wireguard 2>/dev/null",
"echo __DUMP__",
"wg show all dump 2>/dev/null",
].join("; ");
export function parseWireGuardData(output: string): WireGuardData {
if (output.includes("wg_installed=0")) {
return { installed: false, interfaces: [] };
}
const dumpIdx = output.indexOf("__DUMP__");
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
const dumpPart =
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
// Collect up interface names from `ip link show type wireguard`
const upSet = new Set<string>();
for (const line of ipLinkPart.split("\n")) {
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
if (m) upSet.add(m[1]);
}
const ifaceMap = new Map<string, WireGuardInterface>();
for (const raw of dumpPart.split("\n")) {
const line = raw.trim();
if (!line) continue;
const cols = line.split("\t");
if (cols.length === 5) {
// Interface row: iface private_key public_key listen_port fwmark
const [name, , publicKey, listenPortStr] = cols;
if (!name) continue;
ifaceMap.set(name, {
name,
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
listenPort:
listenPortStr && listenPortStr !== "(none)"
? Number(listenPortStr)
: null,
up: upSet.has(name),
peers: [],
});
} else if (cols.length === 9) {
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
const [
name,
publicKey,
,
endpoint,
allowedIPsStr,
handshakeStr,
rxStr,
txStr,
] = cols;
if (!name) continue;
if (!ifaceMap.has(name)) {
ifaceMap.set(name, {
name,
publicKey: null,
listenPort: null,
up: upSet.has(name),
peers: [],
});
}
const handshake = Number(handshakeStr);
ifaceMap.get(name)!.peers.push({
publicKey: publicKey ?? "",
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
allowedIPs:
allowedIPsStr && allowedIPsStr !== "(none)"
? allowedIPsStr.split(",").map((s) => s.trim())
: [],
latestHandshake: handshake > 0 ? handshake : null,
rxBytes: Number(rxStr) || 0,
txBytes: Number(txStr) || 0,
});
}
}
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
* get:
* summary: Get WireGuard interfaces and peers
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: WireGuard installation status, interfaces, and peer details.
*/
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
);
/**
* @openapi
* /host-metrics/managers/wireguard/{id}/action:
* post:
* summary: Bring a WireGuard interface up or down
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* interface:
* type: string
* action:
* type: string
* enum: [up, down]
* responses:
* 200:
* description: Action result.
*/
app.post(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"wireguard_action",
async (client, host, req) => {
const { interface: iface, action } = req.body as {
interface: unknown;
action: unknown;
};
if (!isValidWireGuardInterface(iface)) {
throw new ManagerInputError("Invalid WireGuard interface name");
}
if (!isValidWireGuardAction(action)) {
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
}
const result = await execElevated(
client,
`wg-quick ${action} ${iface}`,
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: (result.stdout + result.stderr).trim(),
};
},
),
);
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockAccess = vi.fn();
vi.mock("fs/promises", () => ({
access: mockAccess,
}));
import { applyAgentAuth, resolveAgentSocket } from "./terminal-auth-helpers.js";
describe("resolveAgentSocket", () => {
const originalEnv = process.env.SSH_AUTH_SOCK;
const originalPlatform = process.platform;
beforeEach(() => {
mockAccess.mockReset();
delete process.env.SSH_AUTH_SOCK;
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SSH_AUTH_SOCK = originalEnv;
} else {
delete process.env.SSH_AUTH_SOCK;
}
Object.defineProperty(process, "platform", { value: originalPlatform });
});
it("uses explicit socket path from terminalConfig over SSH_AUTH_SOCK", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-env/agent.123";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
});
expect(result).toEqual({
socketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
});
expect(mockAccess).toHaveBeenCalledWith(
"/run/user/1000/gnupg/S.gpg-agent.ssh",
);
});
it("falls back to SSH_AUTH_SOCK when no explicit path is provided", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.456";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({});
expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.456" });
});
it("falls back to SSH_AUTH_SOCK when agentSocketPath is empty string", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.789";
mockAccess.mockResolvedValue(undefined);
const result = await resolveAgentSocket({ agentSocketPath: " " });
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({});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain("SSH_AUTH_SOCK");
});
it("returns error when terminalConfig is undefined and SSH_AUTH_SOCK is not set", async () => {
const result = await resolveAgentSocket(undefined);
expect(result).toHaveProperty("error");
});
it("returns error on non-Windows when socket file is missing", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
process.env.SSH_AUTH_SOCK = "/tmp/missing-agent.sock";
mockAccess.mockRejectedValue(new Error("ENOENT"));
const result = await resolveAgentSocket({});
expect(result).toHaveProperty("error");
expect((result as { error: string }).error).toContain(
"/tmp/missing-agent.sock",
);
});
it("skips file existence check on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
process.env.SSH_AUTH_SOCK = "\\\\.\\pipe\\openssh-ssh-agent";
const result = await resolveAgentSocket({});
expect(result).toEqual({
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
});
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();
});
});
+50
View File
@@ -44,6 +44,56 @@ export class MemoryAgent extends BaseAgent {
}
}
export async function resolveAgentSocket(
terminalConfig: Record<string, unknown> | string | 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
)?.trim();
const resolved = explicit || process.env.SSH_AUTH_SOCK;
if (!resolved) {
return {
error: "SSH_AUTH_SOCK is not set and no socket path was provided.",
};
}
if (process.platform !== "win32") {
const { access } = await import("fs/promises");
try {
await access(resolved);
} catch {
return {
error: `SSH agent socket not found at ${resolved}. Make sure your agent is running.`,
};
}
}
return { socketPath: resolved };
}
export async function applyAgentAuth(
connectConfig: Record<string, unknown>,
terminalConfig: Record<string, unknown> | string | undefined,
): Promise<{ socketPath: string } | { error: string }> {
const result = await resolveAgentSocket(terminalConfig);
if ("error" in result) return result;
const { createAgent } = ssh2Pkg;
connectConfig.agent = createAgent(result.socketPath);
return result;
}
export async function performPortKnocking(
host: string,
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
+26 -3
View File
@@ -10,6 +10,8 @@ import {
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
const { Client } = ssh2Pkg;
@@ -24,6 +26,12 @@ interface JumpHostConfig {
keyType?: string;
authType?: string;
credentialId?: number;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
[key: string]: unknown;
}
@@ -149,13 +157,17 @@ export async function createJumpHostChain(
}
}
const firstHopSocks5Config = getJumpHostSocks5Config(
jumpHostConfigs[0],
socks5Config,
);
let proxySocket: import("net").Socket | null = null;
if (socks5Config?.useSocks5) {
if (firstHopSocks5Config?.useSocks5) {
const firstHop = jumpHostConfigs[0];
proxySocket = await createSocks5Connection(
firstHop.ip,
firstHop.port || 22,
socks5Config,
firstHopSocks5Config,
);
}
@@ -174,7 +186,8 @@ export async function createJumpHostChain(
true,
);
const connected = await new Promise<boolean>((resolve) => {
// eslint-disable-next-line no-async-promise-executor
const connected = await new Promise<boolean>(async (resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, 30000);
@@ -275,6 +288,16 @@ export async function createJumpHostChain(
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
| Record<string, unknown>
| undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
jumpClient.on(
+371 -47
View File
@@ -29,8 +29,14 @@ import {
attachOrCreateTmuxSession,
waitForTmuxSession,
} from "./tmux-helper.js";
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
import {
applyAgentAuth,
MemoryAgent,
performPortKnocking,
} from "./terminal-auth-helpers.js";
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { triggerLoginAlert } from "../utils/alert-trigger.js";
interface ConnectToHostData {
cols: number;
@@ -49,6 +55,8 @@ interface ConnectToHostData {
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
passwordFallbackOnly?: boolean;
passwordFallbackAttempted?: boolean;
jumpHosts?: Array<{ hostId: number }>;
useSocks5?: boolean;
socks5Host?: string;
@@ -774,7 +782,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
const opksshData = data as { hostId: number };
try {
const { startOPKSSHAuth } = await import("./opkssh-auth.js");
const { getRequestOrigin } =
const { getRequestBaseUrl } =
await import("../utils/request-origin.js");
const db = getDb();
const hostRow = await db
@@ -801,7 +809,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
const hostname = hostRow[0].name || hostRow[0].ip;
const requestOrigin = getRequestOrigin(req);
const requestOrigin = getRequestBaseUrl(req);
await startOPKSSHAuth(
userId,
opksshData.hostId,
@@ -887,6 +895,110 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
case "vault_start_auth": {
const vaultData = data as { hostId: number };
try {
const { loadVaultProfileForHost, startVaultAuth } =
await import("./vault-oidc-auth.js");
const { getRequestBaseUrl } =
await import("../utils/request-origin.js");
const profile = await loadVaultProfileForHost(vaultData.hostId);
if (!profile) {
ws.send(
JSON.stringify({
type: "vault_error",
hostId: vaultData.hostId,
error: "No Vault signer profile configured for this host",
}),
);
break;
}
const requestOrigin = getRequestBaseUrl(req);
await startVaultAuth(
userId,
vaultData.hostId,
profile,
ws,
requestOrigin,
);
} catch (error) {
sshLogger.error("Failed to start Vault auth", error, {
operation: "vault_start_auth_error",
userId,
hostId: vaultData.hostId,
});
ws.send(
JSON.stringify({
type: "vault_error",
hostId: vaultData.hostId,
error:
error instanceof Error
? error.message
: "Failed to start Vault authentication",
}),
);
}
break;
}
case "vault_cancel": {
const cancelData = data as { hostId: number };
try {
const { cancelVaultAuthByHost } =
await import("./vault-oidc-auth.js");
cancelVaultAuthByHost(userId, cancelData.hostId);
resetConnectionState();
} catch (error) {
sshLogger.error("Failed to cancel Vault auth", error, {
operation: "vault_cancel_error",
userId,
});
}
break;
}
case "vault_auth_completed": {
const completedData = data as {
hostId: number;
cols?: number;
rows?: number;
hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
const reconnectConfig: ConnectToHostData = {
cols: completedData.cols || 80,
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
({
id: completedData.hostId,
ip: "",
port: 22,
username: "",
userId,
} as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
sshLogger.error("Failed to reconnect after Vault auth", error, {
operation: "vault_reconnect_error",
userId,
hostId: completedData.hostId,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect after authentication: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
}
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -993,9 +1105,6 @@ 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, {
@@ -1047,6 +1156,10 @@ 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 &&
@@ -1096,11 +1209,8 @@ 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: resolvedHostData.username || username,
username,
password: resolvedHostData.password,
key: resolvedHostData.key,
keyPassword: keyPassword || resolvedHostData.keyPassword,
@@ -1124,11 +1234,8 @@ 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: resolvedHostData.username || username,
username,
password: resolvedHostData.password,
key: resolvedHostData.key,
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
@@ -1148,6 +1255,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
}
if (hostConfig.passwordFallbackOnly && resolvedCredentials.password) {
resolvedCredentials = {
...resolvedCredentials,
key: undefined,
keyPassword: undefined,
keyType: undefined,
certPublicKey: undefined,
authType: "password",
};
}
sendLog("dns", "info", `Starting address resolution of ${ip}`);
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
sshLogger.success("SSH connection established", {
@@ -1634,6 +1755,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
if (id && hostConfig.userId) {
triggerLoginAlert(
id,
hostConfig.userId,
username,
req.socket.remoteAddress ?? "unknown",
).catch(() => {});
}
if (id && hostConfig.userId) {
(async () => {
try {
@@ -1702,6 +1832,72 @@ 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")
@@ -1750,6 +1946,60 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (
resolvedCredentials.authType === "vault" &&
err.message.includes("All configured authentication methods failed")
) {
sshLogger.warn("Vault certificate authentication failed", {
operation: "vault_auth_failed",
hostId: id,
userId,
error: err.message,
});
(async () => {
try {
const profileId = (
resolvedHostData?.vaultProfile as { id?: number } | undefined
)?.id;
if (profileId) {
const { deleteVaultCert } =
await import("./vault-signer-auth.js");
await deleteVaultCert(userId, profileId);
}
} catch (invalidateError) {
sshLogger.error("Failed to invalidate Vault certificate", {
operation: "vault_cert_invalidation_error",
userId,
hostId: id,
error: invalidateError,
});
}
})();
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
sendLog(
"auth",
"error",
"Vault certificate authentication failed. Please authenticate again.",
);
ws.send(
JSON.stringify({
type: "vault_auth_required",
hostId: id,
message:
"Vault authentication failed or expired. Please authenticate again.",
}),
);
return;
}
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
@@ -2127,19 +2377,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
) {
sendLog("auth", "info", "Using SSH key authentication");
try {
if (
!resolvedCredentials.key.includes("-----BEGIN") ||
!resolvedCredentials.key.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = resolvedCredentials.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
connectConfig.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.key,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword;
@@ -2188,11 +2429,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
}
} catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message);
const message =
keyError instanceof Error
? keyError.message
: "Invalid private key format";
sshLogger.error("SSH key format error: " + message);
ws.send(
JSON.stringify({
type: "error",
message: "SSH key format error: Invalid private key format",
message: `SSH key format error: ${message}`,
}),
);
return;
@@ -2254,6 +2499,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
return;
}
} else if (resolvedCredentials.authType === "vault") {
sendLog("auth", "info", "Using Vault SSH signer authentication");
try {
const vaultProfile = resolvedHostData?.vaultProfile as
| { id: number }
| undefined;
if (!vaultProfile?.id) {
throw new Error("Host has no Vault signer profile configured");
}
const { getVaultCert } = await import("./vault-signer-auth.js");
const cert = await getVaultCert(userId, vaultProfile.id);
if (!cert) {
sendLog(
"auth",
"info",
"No valid Vault certificate found, requesting authentication",
);
ws.send(
JSON.stringify({
type: "vault_auth_required",
hostId: id,
}),
);
return;
}
sendLog("auth", "info", "Using cached Vault-signed certificate");
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
connectConfig,
sshConn,
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
username,
);
} catch (vaultError) {
sshLogger.error("Vault SSH signer authentication error", vaultError, {
operation: "vault_auth_error",
userId,
hostId: id,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Vault SSH signer authentication failed: " +
(vaultError instanceof Error
? vaultError.message
: "Unknown error"),
}),
);
return;
}
} else if (resolvedCredentials.authType === "agent") {
sendLog("auth", "info", "Using SSH agent authentication");
const result = await applyAgentAuth(
connectConfig as Record<string, unknown>,
hostConfig.terminalConfig as Record<string, unknown> | undefined,
);
if ("error" in result) {
ws.send(JSON.stringify({ type: "error", message: result.error }));
return;
}
sendLog(
"auth",
"info",
`SSH agent configured (socket: ${result.socketPath})`,
);
} else {
sendLog("auth", "info", "Using keyboard-interactive authentication");
sshLogger.error("No valid authentication method provided");
@@ -2266,25 +2581,34 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (
hostConfig.terminalConfig?.agentForwarding &&
connectConfig.privateKey
) {
try {
const parsed = ssh2Utils.parseKey(
connectConfig.privateKey as Buffer,
connectConfig.passphrase as string | undefined,
);
if (parsed && !(parsed instanceof Error)) {
connectConfig.agent = new MemoryAgent(parsed);
connectConfig.agentForward = true;
sendLog("auth", "info", "SSH agent forwarding enabled");
if (hostConfig.terminalConfig?.agentForwarding) {
if (connectConfig.privateKey) {
try {
const parsed = ssh2Utils.parseKey(
connectConfig.privateKey as Buffer,
connectConfig.passphrase as string | undefined,
);
if (parsed && !(parsed instanceof Error)) {
connectConfig.agent = new MemoryAgent(parsed);
connectConfig.agentForward = true;
sendLog("auth", "info", "SSH agent forwarding enabled");
}
} catch {
sshLogger.warn("Failed to set up agent forwarding", {
operation: "agent_forward_setup",
hostId: id,
});
}
} catch {
sshLogger.warn("Failed to set up agent forwarding", {
operation: "agent_forward_setup",
hostId: id,
});
} else if (
resolvedCredentials.authType === "agent" &&
connectConfig.agent
) {
connectConfig.agentForward = true;
sendLog(
"auth",
"info",
"SSH agent forwarding enabled (external agent)",
);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { tmuxCommand, withTmuxPath } from "./tmux-helper.js";
describe("tmux command path handling", () => {
it("adds common non-login shell tmux paths", () => {
const command = withTmuxPath("command -v tmux");
expect(command).toContain("/opt/homebrew/bin");
expect(command).toContain("/usr/local/bin");
expect(command).toContain("/opt/bin");
expect(command).toContain("/usr/pkg/bin");
expect(command).toContain(":$PATH; command -v tmux");
});
it("wraps tmux invocations with the same path", () => {
expect(tmuxCommand("list-sessions")).toMatch(
/^PATH=.*:\$PATH; tmux list-sessions$/,
);
});
});
+25 -6
View File
@@ -14,6 +14,21 @@ export interface TmuxDetectionResult {
sessions: TmuxSessionInfo[];
}
const TMUX_PATH_DIRS = [
"/opt/homebrew/bin",
"/usr/local/bin",
"/opt/bin",
"/usr/pkg/bin",
];
export function withTmuxPath(command: string): string {
return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`;
}
export function tmuxCommand(args: string): string {
return withTmuxPath(`tmux ${args}`);
}
/**
* Run a command on the remote host via a separate exec channel.
* Returns stdout as a string. Does not pollute the interactive shell.
@@ -54,7 +69,7 @@ export function execCommand(conn: Client, command: string): Promise<string> {
*/
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
try {
await execCommand(conn, "command -v tmux");
await execCommand(conn, withTmuxPath("command -v tmux"));
} catch {
return { available: false, sessions: [] };
}
@@ -63,7 +78,9 @@ export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
try {
const output = await execCommand(
conn,
`tmux list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
tmuxCommand(
`list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
),
);
if (output) {
sessions = output
@@ -126,7 +143,7 @@ export async function waitForTmuxSession(
try {
await execCommand(
conn,
`tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`,
tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`),
);
return sessionName;
} catch {
@@ -148,10 +165,10 @@ export function attachOrCreateTmuxSession(
): void {
let command: string;
if (existingSessionName) {
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
} else {
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`;
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
}
sshLogger.info("Writing tmux command to shell", {
@@ -172,7 +189,9 @@ export async function queryNewestTmuxSession(
try {
const output = await execCommand(
conn,
`tmux list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`,
tmuxCommand(
`list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`,
),
);
return output || null;
} catch {
+47 -22
View File
@@ -10,15 +10,17 @@ import { tmuxSessionTags, users } from "../database/db/schema.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { sshLogger } from "../utils/logger.js";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { resolveHostById, checkHostAccess } from "./host-resolver.js";
import { createJumpHostChain } from "./jump-host-chain.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { withConnection } from "./ssh-connection-pool.js";
import { execCommand } from "./tmux-helper.js";
import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
import {
SEP,
parseSessions,
@@ -81,22 +83,26 @@ async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
}
base.password = host.password;
} else if (host.authType === "key") {
if (!host.key || !host.key.includes("-----BEGIN")) {
if (!host.key) {
throw new Error(`No valid SSH key available for host ${host.ip}`);
}
const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as Record<string, unknown>).privateKey = Buffer.from(
cleanKey,
"utf8",
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
host.key,
host.keyPassword,
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} else if (host.authType === "none") {
// no credentials needed
} else if (host.authType === "agent") {
const result = await applyAgentAuth(
base as Record<string, unknown>,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else {
// opkssh and other interactive flows are not supported by this module
throw new Error(
@@ -222,7 +228,7 @@ async function withHostConnection<T>(
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, "command -v tmux");
await execCommand(conn, withTmuxPath("command -v tmux"));
return true;
} catch {
return false;
@@ -238,15 +244,21 @@ async function runTmuxList(conn: Client, command: string): Promise<string> {
}
function listSessionsCmd(): string {
return `tmux list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`;
return tmuxCommand(
`list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`,
);
}
function listWindowsCmd(): string {
return `tmux list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`;
return tmuxCommand(
`list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`,
);
}
function listPanesCmd(): string {
return `tmux list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`;
return tmuxCommand(
`list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`,
);
}
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
@@ -507,7 +519,9 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
// containing the pane.
execCommand(
conn,
`tmux select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
tmuxCommand(
`select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
),
),
);
res.json({ ok: true });
@@ -528,7 +542,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux new-session -d -s ${shellEscape(name)}`),
execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)),
);
sshLogger.info("tmux session created", {
operation: "tmux_session_create",
@@ -563,7 +577,10 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux new-window -t ${shellEscape(`=${sessionName}`)}`),
execCommand(
conn,
tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`),
),
);
sshLogger.info("tmux window created", {
operation: "tmux_window_create",
@@ -599,7 +616,9 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
tmuxCommand(
`rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
),
),
);
await getDb()
@@ -651,7 +670,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux kill-session -t ${shellEscape(`=${sessionName}`)}`,
tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`),
),
);
await getDb()
@@ -698,7 +717,9 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
tmuxCommand(
`kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
),
),
);
sshLogger.info("tmux window killed", {
@@ -736,7 +757,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux kill-pane -t ${shellEscape(paneId)}`),
execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)),
);
sshLogger.info("tmux pane killed", {
operation: "tmux_pane_kill",
@@ -774,7 +795,9 @@ app.post("/tmux_monitor/:hostId/split", async (req, res) => {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
tmuxCommand(
`split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
),
),
);
sshLogger.info("tmux pane split", {
@@ -822,7 +845,9 @@ app.get("/tmux_monitor/:hostId/search", async (req, res) => {
try {
const output = await execCommand(
conn,
`tmux capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
tmuxCommand(
`capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
),
);
const lines = output.split("\n").filter(Boolean);
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
+181 -102
View File
@@ -32,6 +32,7 @@ import { createSocks5Connection } from "../utils/socks5-helper.js";
import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { withConnection } from "./ssh-connection-pool.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import {
applyAuthOptions,
bindForwardIn,
@@ -94,6 +95,66 @@ const activeTunnelProcesses = new Map<string, ChildProcess>();
const pendingTunnelOperations = new Map<string, Promise<void>>();
const tunnelStatusClients = new Set<Response>();
const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host";
const AUTOSTART_FETCH_RETRIES = 6;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function describeAxiosError(error: unknown): string {
if (axios.isAxiosError(error)) {
return error.response
? `${error.response.status} ${error.response.statusText}`
: error.message;
}
return error instanceof Error ? error.message : "Unknown error";
}
async function fetchInternalHosts(
path: "internal" | "internal/all",
internalAuthToken: string,
): Promise<SSHHost[]> {
let lastError: unknown;
for (let attempt = 1; attempt <= AUTOSTART_FETCH_RETRIES; attempt++) {
try {
const response = await axios.get(
`${INTERNAL_HOST_API_BASE_URL}/${path}`,
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
timeout: 5000,
},
);
return response.data || [];
} catch (error) {
lastError = error;
if (attempt === AUTOSTART_FETCH_RETRIES) {
break;
}
const retryDelayMs = Math.min(500 * 2 ** (attempt - 1), 5000);
tunnelLogger.warn("Internal host API unavailable, retrying", {
operation: "tunnel_autostart_fetch_retry",
path,
attempt,
maxAttempts: AUTOSTART_FETCH_RETRIES,
retryDelayMs,
error: describeAxiosError(error),
});
await sleep(retryDelayMs);
}
}
throw new Error(
`Failed to fetch ${path} hosts after ${AUTOSTART_FETCH_RETRIES} attempts: ${describeAxiosError(lastError)}`,
);
}
type ActiveTunnelRuntime = {
sourceClient: Client;
endpointClient?: Client;
@@ -106,6 +167,24 @@ type ActiveTunnelRuntime = {
const activeTunnelRuntimes = new Map<string, ActiveTunnelRuntime>();
function findHostByTunnelEndpoint(
hosts: SSHHost[],
endpointHost?: string,
): SSHHost | undefined {
const value = endpointHost?.trim();
if (!value) return undefined;
return hosts.find((host) => {
const userAtIp = `${host.username}@${host.ip}`;
return (
String(host.id) === value ||
host.name === value ||
host.ip === value ||
userAtIp === value
);
});
}
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
if (
status.status === CONNECTION_STATES.CONNECTED &&
@@ -550,6 +629,12 @@ function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean {
return false;
}
function shouldEstablishDirectTunnel(tunnelConfig: TunnelConfig): boolean {
if (isSingleHostTunnel(tunnelConfig)) return true;
const mode = getTunnelMode(tunnelConfig);
return mode !== "remote" && !tunnelConfig.endpointUsername;
}
async function establishDirectTunnel(
sourceClient: Client,
tunnelConfig: TunnelConfig,
@@ -558,7 +643,8 @@ async function establishDirectTunnel(
const mode = getTunnelMode(tunnelConfig);
const bindHost = getTunnelBindHost(tunnelConfig);
const sourcePort = tunnelConfig.sourcePort;
const targetHost = tunnelConfig.targetHost || "127.0.0.1";
const targetHost =
tunnelConfig.targetHost || tunnelConfig.endpointHost || "127.0.0.1";
const targetPort = tunnelConfig.endpointPort;
if (mode === "remote") {
@@ -993,6 +1079,7 @@ async function connectSSHTunnel(
}
if (
!shouldEstablishDirectTunnel(tunnelConfig) &&
resolvedEndpointCredentials.authMethod === "password" &&
!resolvedEndpointCredentials.password
) {
@@ -1013,6 +1100,7 @@ async function connectSSHTunnel(
}
if (
!shouldEstablishDirectTunnel(tunnelConfig) &&
resolvedEndpointCredentials.authMethod === "key" &&
!resolvedEndpointCredentials.sshKey
) {
@@ -1166,7 +1254,7 @@ async function connectSSHTunnel(
);
}
if (isSingleHostTunnel(tunnelConfig)) {
if (shouldEstablishDirectTunnel(tunnelConfig)) {
await establishDirectTunnel(conn, tunnelConfig);
} else {
await establishManagedS2STunnel(
@@ -1300,37 +1388,33 @@ async function connectSSHTunnel(
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
if (
!resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
!resolvedSourceCredentials.sshKey.includes("-----END")
) {
try {
connOptions.privateKey = preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid SSH key format";
tunnelLogger.error(
`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`,
`Invalid SSH key format for tunnel '${tunnelName}': ${message}`,
undefined,
{
operation: "tunnel_invalid_ssh_key_format",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
keyType: resolvedSourceCredentials.keyType,
hasBeginMarker:
resolvedSourceCredentials.sshKey.includes("-----BEGIN"),
hasEndMarker: resolvedSourceCredentials.sshKey.includes("-----END"),
},
);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "Invalid SSH key format",
reason: message,
});
tunnelConnecting.delete(tunnelName);
return;
}
const cleanKey = resolvedSourceCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
@@ -1482,11 +1566,15 @@ async function killRemoteTunnelByMarker(
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
if (
!resolvedSourceCredentials.sshKey.includes("-----BEGIN") ||
!resolvedSourceCredentials.sshKey.includes("-----END")
) {
callback(new Error("Invalid SSH key format"));
try {
preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
} catch (error) {
callback(
error instanceof Error ? error : new Error("Invalid SSH key format"),
);
return;
}
}
@@ -1542,11 +1630,10 @@ async function killRemoteTunnelByMarker(
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
const cleanKey = resolvedSourceCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
connOptions.privateKey = preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
@@ -1931,51 +2018,56 @@ app.post(
);
const allHosts: SSHHost[] = allHostsResponse.data || [];
const endpointHost = allHosts.find(
(h) =>
h.name === tunnelConfig.endpointHost ||
`${h.username}@${h.ip}` === tunnelConfig.endpointHost,
const endpointHost = findHostByTunnelEndpoint(
allHosts,
tunnelConfig.endpointHost,
);
if (!endpointHost) {
throw new Error(
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
);
}
tunnelConfig.endpointIP = endpointHost.ip;
tunnelConfig.endpointSSHPort = endpointHost.port;
tunnelConfig.endpointUsername = endpointHost.username;
tunnelConfig.endpointAuthMethod = endpointHost.authType;
tunnelConfig.endpointKeyType = endpointHost.keyType;
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
tunnelConfig.endpointUserId = endpointHost.userId;
// Resolve credentials server-side instead of from HTTP response
if (endpointHost.id && endpointHost.userId) {
try {
const { resolveHostById } = await import("./host-resolver.js");
const resolved = await resolveHostById(
endpointHost.id,
endpointHost.userId,
if (getTunnelMode(tunnelConfig) !== "remote") {
tunnelConfig.endpointIP =
tunnelConfig.endpointIP || tunnelConfig.endpointHost;
} else {
throw new Error(
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
);
if (resolved) {
tunnelConfig.endpointPassword = resolved.password;
tunnelConfig.endpointSSHKey = resolved.key;
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
}
} else {
tunnelConfig.endpointIP = endpointHost.ip;
tunnelConfig.endpointSSHPort = endpointHost.port;
tunnelConfig.endpointUsername = endpointHost.username;
tunnelConfig.endpointAuthMethod = endpointHost.authType;
tunnelConfig.endpointKeyType = endpointHost.keyType;
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
tunnelConfig.endpointUserId = endpointHost.userId;
// Resolve credentials server-side instead of from HTTP response
if (endpointHost.id && endpointHost.userId) {
try {
const { resolveHostById } =
await import("./host-resolver.js");
const resolved = await resolveHostById(
endpointHost.id,
endpointHost.userId,
);
if (resolved) {
tunnelConfig.endpointPassword = resolved.password;
tunnelConfig.endpointSSHKey = resolved.key;
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
}
} catch (credError) {
tunnelLogger.warn(
"Failed to resolve endpoint credentials from DB",
{
operation: "tunnel_endpoint_credential_resolve",
endpointHostId: endpointHost.id,
error:
credError instanceof Error
? credError.message
: "Unknown",
},
);
}
} catch (credError) {
tunnelLogger.warn(
"Failed to resolve endpoint credentials from DB",
{
operation: "tunnel_endpoint_credential_resolve",
endpointHostId: endpointHost.id,
error:
credError instanceof Error
? credError.message
: "Unknown",
},
);
}
}
} catch (resolveError) {
@@ -2237,28 +2329,14 @@ async function initializeAutoStartTunnels(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance();
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const autostartResponse = await axios.get(
"http://localhost:30001/host/db/host/internal",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
const autostartHosts = await fetchInternalHosts(
"internal",
internalAuthToken,
);
const allHostsResponse = await axios.get(
"http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
"X-Internal-Auth-Token": internalAuthToken,
},
},
const allHosts = await fetchInternalHosts(
"internal/all",
internalAuthToken,
);
const autostartHosts: SSHHost[] = autostartResponse.data || [];
const allHosts: SSHHost[] = allHostsResponse.data || [];
const autoStartTunnels: TunnelConfig[] = [];
tunnelLogger.info(
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
@@ -2268,13 +2346,15 @@ async function initializeAutoStartTunnels(): Promise<void> {
if (host.enableTunnel && host.tunnelConnections) {
for (const tunnelConnection of host.tunnelConnections) {
if (tunnelConnection.autoStart) {
const endpointHost = allHosts.find(
(h) =>
h.name === tunnelConnection.endpointHost ||
`${h.username}@${h.ip}` === tunnelConnection.endpointHost,
const endpointHost = findHostByTunnelEndpoint(
allHosts,
tunnelConnection.endpointHost,
);
const mode =
tunnelConnection.mode || tunnelConnection.tunnelType || "remote";
const allowDirectTarget = mode !== "remote";
if (endpointHost) {
if (endpointHost || allowDirectTarget) {
const tunnelIndex =
host.tunnelConnections.indexOf(tunnelConnection);
const tunnelConfig: TunnelConfig = {
@@ -2287,10 +2367,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
tunnelConnection.endpointPort,
),
scope: tunnelConnection.scope || "s2s",
mode:
tunnelConnection.mode ||
tunnelConnection.tunnelType ||
"remote",
mode,
bindHost: tunnelConnection.bindHost,
targetHost: tunnelConnection.targetHost,
tunnelType: tunnelConnection.tunnelType || "remote",
@@ -2304,16 +2381,18 @@ async function initializeAutoStartTunnels(): Promise<void> {
sourceKeyType: host.keyType,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointIP: endpointHost?.ip || tunnelConnection.endpointHost,
endpointSSHPort: endpointHost?.port || 22,
endpointUsername: endpointHost?.username || "",
endpointHost: tunnelConnection.endpointHost,
endpointAuthMethod:
tunnelConnection.endpointAuthType || endpointHost.authType,
tunnelConnection.endpointAuthType ||
endpointHost?.authType ||
"none",
endpointKeyType:
tunnelConnection.endpointKeyType || endpointHost.keyType,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
tunnelConnection.endpointKeyType || endpointHost?.keyType,
endpointCredentialId: endpointHost?.credentialId,
endpointUserId: endpointHost?.userId,
sourcePort: tunnelConnection.sourcePort,
endpointPort: tunnelConnection.endpointPort,
maxRetries: tunnelConnection.maxRetries,
+216
View File
@@ -0,0 +1,216 @@
// Orchestrates the interactive Vault OIDC login that yields a signed,
// short-lived SSH certificate. Mirrors the OPKSSH session manager but is
// driven entirely over Vault's HTTP API (no external binary).
//
// Lifecycle:
// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC
// auth_url, stash a pending session keyed by the Vault-issued `state`,
// and send the auth_url to the browser over the terminal WebSocket.
// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH.
// - completeVaultAuth(): exchange the code for a Vault token, sign the
// 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 { sshLogger } from "../utils/logger.js";
import {
type VaultProfileConfig,
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
storeVaultCert,
} from "./vault-signer-auth.js";
export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback";
const AUTH_TIMEOUT = 5 * 60 * 1000;
interface VaultAuthSession {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
ephemeralPrivateKey: string;
ephemeralPublicKey: string;
ws: WebSocket;
timeout: NodeJS.Timeout;
}
// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback
// can correlate back to the originating session.
const sessions = new Map<string, VaultAuthSession>();
function rowToProfileConfig(row: Record<string, unknown>): VaultProfileConfig {
return {
id: row.id as number,
vaultAddr: row.vaultAddr as string,
vaultNamespace: (row.vaultNamespace as string | null) ?? null,
oidcMount: (row.oidcMount as string | null) ?? null,
oidcRole: (row.oidcRole as string | null) ?? null,
sshMount: (row.sshMount as string | null) ?? null,
sshRole: row.sshRole as string,
validPrincipals: (row.validPrincipals as string | null) ?? null,
keyType: (row.keyType as string | null) ?? null,
};
}
/** Load the Vault profile referenced by a host, or null if not configured. */
export async function loadVaultProfileForHost(
hostId: number,
): 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 profileRows = await db
.select()
.from(vaultProfiles)
.where(eq(vaultProfiles.id, hostRows[0].vaultProfileId as number))
.limit(1);
if (!profileRows.length) return null;
return rowToProfileConfig(profileRows[0] as Record<string, unknown>);
}
/**
* Begin an interactive Vault OIDC login and send the auth URL to the browser.
*/
export async function startVaultAuth(
userId: string,
hostId: number,
profile: VaultProfileConfig,
ws: WebSocket,
requestOrigin: string,
): Promise<void> {
const ephemeral = generateEphemeralKeyPair(profile.keyType);
const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) clearTimeout(existing.timeout);
const timeout = setTimeout(() => {
sessions.delete(state);
}, AUTH_TIMEOUT);
sessions.set(state, {
state,
userId,
hostId,
profile,
clientNonce,
ephemeralPrivateKey: ephemeral.privateKey,
ephemeralPublicKey: ephemeral.publicKey,
ws,
timeout,
});
sshLogger.info("Started Vault OIDC auth session", {
operation: "vault_oidc_start",
userId,
hostId,
profileId: profile.id,
});
ws.send(
JSON.stringify({
type: "vault_auth_url",
hostId,
url: authUrl,
}),
);
}
/**
* Complete a Vault OIDC login from the browser callback: exchange the code for a
* Vault token, sign the ephemeral key, cache the certificate, and notify the
* browser to resume the SSH connection.
*/
export async function completeVaultAuth(
state: string,
code: string,
): Promise<{ ok: boolean; error?: string }> {
const session = sessions.get(state);
if (!session) {
return { ok: false, error: "No matching Vault authentication session" };
}
try {
const token = await completeVaultOidc(session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signedCert = await signWithVault(
session.profile,
token,
session.ephemeralPublicKey,
);
const expiresAt = await storeVaultCert(
session.userId,
session.profile.id,
session.ephemeralPrivateKey,
signedCert,
);
sshLogger.success("Completed Vault OIDC auth and cached certificate", {
operation: "vault_oidc_complete",
userId: session.userId,
hostId: session.hostId,
profileId: session.profile.id,
});
session.ws.send(
JSON.stringify({
type: "vault_completed",
hostId: session.hostId,
expiresAt,
}),
);
return { ok: true };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
sshLogger.error("Vault OIDC completion failed", e, {
operation: "vault_oidc_complete_error",
userId: session.userId,
hostId: session.hostId,
});
try {
session.ws.send(
JSON.stringify({
type: "vault_error",
hostId: session.hostId,
error: msg,
}),
);
} catch {
// ws may be closed
}
return { ok: false, error: msg };
} finally {
clearTimeout(session.timeout);
sessions.delete(state);
}
}
/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */
export function cancelVaultAuthByHost(userId: string, hostId: number): void {
for (const [state, s] of sessions) {
if (s.userId === userId && s.hostId === hostId) {
clearTimeout(s.timeout);
sessions.delete(state);
}
}
}
+167
View File
@@ -0,0 +1,167 @@
// HashiCorp Vault SSH signer authentication.
//
// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP):
// 1. Generate an ephemeral SSH keypair (never persisted long-term).
// 2. The user authenticates to Vault via an interactive OIDC flow
// (auth/<mount>/oidc/auth_url -> browser -> auth/<mount>/oidc/callback),
// yielding a short-lived Vault token.
// 3. Vault's SSH secrets engine signs the ephemeral public key
// (<sshMount>/sign/<role>) -> short-lived OpenSSH certificate.
// 4. The ephemeral private key + certificate are cached per-user (encrypted)
// until the certificate expires, then used to connect via setupOPKSSHCertAuth.
//
// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored.
// Vault connection SETTINGS live in shareable vault_profiles rows.
//
// 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 { UserCrypto } from "../utils/user-crypto.js";
import { FieldCrypto } from "../utils/field-crypto.js";
import { parseCertValidBefore } from "./vault-signer-core.js";
export type {
VaultProfileConfig,
EphemeralKeyPair,
} from "./vault-signer-core.js";
export {
generateEphemeralKeyPair,
startVaultOidc,
completeVaultOidc,
signWithVault,
parseCertValidBefore,
} from "./vault-signer-core.js";
// Re-sign once we're within this many seconds of expiry.
const EXPIRY_SKEW_SECONDS = 60;
function cacheRecordId(userId: string, profileId: number): string {
return `vault-${userId}-${profileId}`;
}
/**
* Store an ephemeral private key + signed certificate for a user/profile.
* Returns the expiry as an ISO string.
*/
export async function storeVaultCert(
userId: string,
profileId: number,
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");
}
const validBefore = parseCertValidBefore(signedCert);
const expiresMs =
validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000;
const expiresAt = new Date(expiresMs).toISOString();
const recordId = cacheRecordId(userId, profileId);
const encryptedCert = FieldCrypto.encryptField(
signedCert,
userDataKey,
recordId,
"ssh_cert",
);
const encryptedKey = FieldCrypto.encryptField(
privateKey,
userDataKey,
recordId,
"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(),
},
});
return expiresAt;
}
/**
* Return a cached, still-valid ephemeral key + certificate for a user/profile,
* or null if none exists or it has (nearly) expired.
*/
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);
if (!rows || rows.length === 0) return null;
const row = rows[0];
const expiresMs = new Date(row.expiresAt).getTime();
if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) {
await deleteVaultCert(userId, profileId);
return null;
}
const userDataKey = UserCrypto.getInstance().getUserDataKey(userId);
if (!userDataKey) {
throw new Error("User data key not found");
}
const recordId = cacheRecordId(userId, profileId);
const sshCert = FieldCrypto.decryptField(
row.sshCert,
userDataKey,
recordId,
"ssh_cert",
);
const privateKey = FieldCrypto.decryptField(
row.privateKey,
userDataKey,
recordId,
"private_key",
);
await db
.update(vaultTokens)
.set({ lastUsed: new Date().toISOString() })
.where(
and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)),
);
return { privateKey, sshCert };
}
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)),
);
}
+268
View File
@@ -0,0 +1,268 @@
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
import { execFileSync } from "child_process";
import { mkdtempSync, readFileSync, rmSync } from "fs";
import os from "os";
import path from "path";
import ssh2Pkg from "ssh2";
import {
generateEphemeralKeyPair,
parseCertValidBefore,
startVaultOidc,
completeVaultOidc,
signWithVault,
type VaultProfileConfig,
} from "./vault-signer-core.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
// Both halves must be parseable by the same library that signs/connects.
const priv = ssh2Utils.parseKey(pair.privateKey);
expect(priv instanceof Error).toBe(false);
const pub = ssh2Utils.parseKey(pair.publicKey);
expect(pub instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/ca`,
"-N",
"",
"-q",
]);
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/user`,
"-N",
"",
"-q",
]);
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) {
console.warn("ssh-keygen unavailable; skipping real-cert parse test");
return;
}
const validBefore = parseCertValidBefore(cert);
// -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute)
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("Vault HTTP flow (mocked fetch)", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
afterEach(() => {
vi.unstubAllGlobals();
});
function mockFetch(
impl: (
url: string,
init: RequestInit,
) => { status?: number; body: unknown },
) {
const calls: Array<{ url: string; init: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
calls.push({ url, init });
const { status = 200, body } = impl(url, init);
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
} as Response;
}),
);
return calls;
}
it("startVaultOidc posts auth_url and extracts state", async () => {
const calls = mockFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
profile,
"https://termix/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init.method).toBe("POST");
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
role: "developer",
redirect_uri: "https://termix/vault/oidc/callback",
});
expect(body.client_nonce).toBe(result.clientNonce);
});
it("completeVaultOidc returns the client token", async () => {
const calls = mockFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const calls = mockFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
const headers = calls[0].init.headers as Record<string, string>;
expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(headers["X-Vault-Namespace"]).toBe("team-a");
const body = JSON.parse(calls[0].init.body as string);
expect(body).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
mockFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
});
// Live integration against a real Vault (e.g. `vault server -dev` in Docker).
// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount
// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness.
describe("Vault live signing", () => {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
const run = !!addr && !!token;
it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => {
const profile: VaultProfileConfig = {
id: 99,
vaultAddr: addr!,
sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer",
sshRole: process.env.VAULT_SSH_ROLE || "my-role",
validPrincipals: "root",
keyType: "ssh-ed25519",
};
const pair = generateEphemeralKeyPair(profile.keyType);
const before = Math.floor(Date.now() / 1000);
const cert = await signWithVault(profile, token!, pair.publicKey);
expect(cert).toMatch(/-cert-v01@openssh\.com /);
// The signed cert must parse with the same library used to connect.
const parsed = ssh2Utils.parseKey(cert);
expect(parsed instanceof Error).toBe(false);
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(before);
});
});
+265
View File
@@ -0,0 +1,265 @@
// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation,
// the Vault OIDC HTTP flow, certificate signing, and certificate parsing.
//
// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user
// certificate cache) so this layer can be unit-tested without the native SQLite
// dependency.
import crypto from "crypto";
import ssh2Pkg from "ssh2";
import { sshLogger } from "../utils/logger.js";
const { utils: ssh2Utils } = ssh2Pkg;
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
async function vaultRequest(
url: string,
method: "GET" | "POST" | "PUT",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<any> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: any;
if (text) {
try {
json = JSON.parse(text);
} catch {
// leave undefined for non-JSON bodies
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** Generate an ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
ssh2Type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
ssh2Type = "ecdsa";
options.bits = 256;
break;
case "ssh-ed25519":
default:
ssh2Type = "ed25519";
break;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce
* needed to complete the callback. `redirectUri` must be allowed both by the
* Vault role's allowed_redirect_uris and by the IdP.
*/
export async function startVaultOidc(
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault embeds the state it generated in the auth_url; we need it to correlate
// the browser callback back to this login attempt.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Complete the Vault OIDC callback and return a short-lived Vault token. */
export async function completeVaultOidc(
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile));
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken };
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
headers,
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* Parse the "valid before" Unix timestamp out of an OpenSSH certificate.
* Returns 0 if it can't be parsed (caller falls back to a conservative TTL).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const readString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
readString(); // format id
readString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) readString();
readUint64(); // serial
pos += 4; // type (uint32)
readString(); // key id
readString(); // valid principals
readUint64(); // valid after
return readUint64(); // valid before
} catch (e) {
sshLogger.warn("Failed to parse Vault-signed certificate expiry", {
operation: "vault_cert_parse",
error: e instanceof Error ? e.message : String(e),
});
return 0;
}
}
+30 -8
View File
@@ -58,14 +58,36 @@ export async function collectNetworkMetrics(client: Client): Promise<{
}
}
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
try {
const procNet = await execCommand(client, "cat /proc/net/dev");
const rxTxMap = new Map<string, { rx: string; tx: string }>();
for (const line of procNet.stdout.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
const ifName = parts[0].replace(":", "");
rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
});
}
} catch {
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
}
}
} catch {
// expected
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
parseSensorsOutput,
parseSysfsThermalOutput,
} from "./temperature-collector.js";
describe("temperature collectors", () => {
it("parses sysfs thermal zone output", () => {
const result = parseSysfsThermalOutput(
"x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n",
);
expect(result).toEqual([
{ label: "x86_pkg_temp", celsius: 51 },
{ label: "acpitz", celsius: 42 },
]);
});
it("parses lm-sensors temperature lines", () => {
const result = parseSensorsOutput(`
coretemp-isa-0000
Adapter: ISA adapter
Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C)
Core 0: +48.5°C
fan1: 1200 RPM
`);
expect(result).toEqual([
{ label: "Package id 0", celsius: 52 },
{ label: "Core 0", celsius: 48.5 },
]);
});
});
@@ -0,0 +1,117 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
function normalizeSensor(
label: string,
celsius: number,
): TemperatureSensor | null {
const cleanLabel = label.trim().replace(/\s+/g, " ");
if (!cleanLabel || !Number.isFinite(celsius)) return null;
return {
label: cleanLabel,
celsius: toFixedNum(celsius, 1) ?? celsius,
};
}
function summarizeSensors(
source: TemperatureMetrics["source"],
sensors: TemperatureSensor[],
): TemperatureMetrics {
const highest = sensors.reduce<number | null>(
(max, sensor) =>
max === null ? sensor.celsius : Math.max(max, sensor.celsius),
null,
);
return {
source: sensors.length > 0 ? source : "none",
highestCelsius:
highest === null ? null : (toFixedNum(highest, 1) ?? highest),
sensors,
};
}
export function parseSysfsThermalOutput(output: string): TemperatureSensor[] {
return output
.split("\n")
.map((line) => {
const [label, rawValue] = line.split("\t");
const value = Number(rawValue);
if (!label || !Number.isFinite(value)) return null;
const celsius = Math.abs(value) >= 1000 ? value / 1000 : value;
return normalizeSensor(label, celsius);
})
.filter((sensor): sensor is TemperatureSensor => sensor !== null);
}
export function parseSensorsOutput(output: string): TemperatureSensor[] {
const seen = new Set<string>();
const sensors: TemperatureSensor[] = [];
for (const line of output.split("\n")) {
const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i);
if (!match) continue;
const sensor = normalizeSensor(match[1], Number(match[2]));
if (!sensor) continue;
const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`;
if (seen.has(key)) continue;
seen.add(key);
sensors.push(sensor);
}
return sensors;
}
export async function collectTemperatureMetrics(
client: Client,
): Promise<TemperatureMetrics> {
try {
const sysfs = await execCommand(
client,
[
"for zone in /sys/class/thermal/thermal_zone*; do",
'[ -r "$zone/temp" ] || continue;',
'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";',
'value="$(cat "$zone/temp" 2>/dev/null || true)";',
'[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";',
"done",
].join(" "),
10000,
);
const sensors = parseSysfsThermalOutput(sysfs.stdout);
if (sensors.length > 0) {
return summarizeSensors("sysfs", sensors);
}
} catch {
// expected on systems without readable thermal zones
}
try {
const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000);
const sensors = parseSensorsOutput(lmSensors.stdout);
if (sensors.length > 0) {
return summarizeSensors("sensors", sensors);
}
} catch {
// expected when lm-sensors is not installed
}
return {
source: "none",
highestCelsius: null,
sensors: [],
};
}