mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
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:
@@ -17,8 +17,11 @@ import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import proxmoxRoutes from "./routes/proxmox.js";
|
||||
import termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1788,8 +1791,11 @@ app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/proxmox", proxmoxRoutes);
|
||||
app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/", alertRulesRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
@@ -196,6 +197,22 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -636,12 +653,11 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
const defaultGuacUrl = `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run(defaultGuacUrl);
|
||||
.run(getDefaultGuacdUrl());
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
@@ -689,12 +705,29 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
|
||||
addColumnIfNotExists(
|
||||
"user_preferences",
|
||||
"expand_app_rail_on_hover",
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
@@ -714,6 +747,24 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
@@ -784,6 +835,11 @@ const migrateSchema = () => {
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"vault_profile_id",
|
||||
"INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
@@ -990,6 +1046,111 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identities table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce one-Termix-ID-per-user on databases where the table predates the
|
||||
// UNIQUE(user_id) constraint above.
|
||||
try {
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
|
||||
);
|
||||
} catch (indexError) {
|
||||
databaseLogger.warn("Failed to create termix_identities user_id unique index", {
|
||||
operation: "schema_migration",
|
||||
error: indexError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identity_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
label TEXT,
|
||||
comment TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
credential_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_keys table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The public resolver fetches keys by identity_id on every request; index it.
|
||||
try {
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
|
||||
);
|
||||
} catch (indexError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_keys identity index", {
|
||||
operation: "schema_migration",
|
||||
error: indexError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identity_ca (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
validity_days INTEGER NOT NULL DEFAULT 90,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_ca table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1470,6 +1631,67 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
vault_addr TEXT NOT NULL,
|
||||
vault_namespace TEXT,
|
||||
oidc_mount TEXT,
|
||||
oidc_role TEXT,
|
||||
ssh_mount TEXT,
|
||||
ssh_role TEXT NOT NULL,
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create vault_profiles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create vault_tokens table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1711,6 +1933,197 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// --- metrics-history begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
mem_percent REAL,
|
||||
disk_percent REAL,
|
||||
net_rx_bytes INTEGER,
|
||||
net_tx_bytes INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
|
||||
ON host_metrics_history (host_id, ts DESC);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create host_metrics_history table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- metrics-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
threshold_value REAL,
|
||||
threshold_duration_seconds INTEGER,
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rules table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create notification_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rule_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_firings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TEXT,
|
||||
value REAL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
acknowledged INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
|
||||
ON alert_firings (user_id, fired_at DESC);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_firings table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- alerts end ---
|
||||
|
||||
// Seed default metrics history retention setting
|
||||
try {
|
||||
const retentionRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
|
||||
.get();
|
||||
if (!retentionRow) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
// --- homepage begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_items table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_layouts table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
@@ -80,6 +80,25 @@ export const trustedDevices = sqliteTable("trusted_devices", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const webauthnCredentials = sqliteTable("webauthn_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
credentialId: text("credential_id").notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: integer("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
});
|
||||
|
||||
export const hosts = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -111,6 +130,13 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
// When authType is "vault", the host authenticates via a Vault SSH signer
|
||||
// profile (shared settings, no secrets). The signing certificate is obtained
|
||||
// per-user at connect time via an interactive Vault OIDC flow.
|
||||
vaultProfileId: integer("vault_profile_id").references(
|
||||
() => vaultProfiles.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -661,6 +687,63 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||
// are intended to be shared across users (shared === true makes a profile
|
||||
// visible to every user on the server). Each user authenticates to Vault via an
|
||||
// interactive OIDC flow at connect time; no tokens or keys are stored here.
|
||||
export const vaultProfiles = sqliteTable("vault_profiles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
vaultNamespace: text("vault_namespace"),
|
||||
// OIDC auth method mount + role used to obtain a Vault token interactively
|
||||
oidcMount: text("oidc_mount"),
|
||||
oidcRole: text("oidc_role"),
|
||||
// SSH secrets engine mount + signer role used to sign the ephemeral key
|
||||
sshMount: text("ssh_mount"),
|
||||
sshRole: text("ssh_role").notNull(),
|
||||
validPrincipals: text("valid_principals"),
|
||||
// Ephemeral keypair algorithm to generate per connection
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||
// Transient: rows live only until the certificate expires. Secret fields are
|
||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||
export const vaultTokens = sqliteTable("vault_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
profileId: integer("profile_id")
|
||||
.notNull()
|
||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
@@ -710,6 +793,9 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
showHostTags: integer("show_host_tags", { mode: "boolean" }),
|
||||
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
|
||||
pinAppRail: integer("pin_app_rail", { mode: "boolean" }),
|
||||
expandAppRailOnHover: integer("expand_app_rail_on_hover", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
|
||||
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
|
||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||
@@ -788,6 +874,79 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// --- termix-id begin ---
|
||||
// A user claims a unique public handle. Their published SSH public keys are
|
||||
// served at an unauthenticated resolver endpoint in authorized_keys format,
|
||||
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
|
||||
export const termixIdentities = sqliteTable("termix_identities", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// One Termix ID per user — enforced in schema, not just in code.
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
handle: text("handle").notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const termixIdentityKeys = sqliteTable("termix_identity_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identityId: integer("identity_id")
|
||||
.notNull()
|
||||
.references(() => termixIdentities.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Public keys are non-secret, so they are stored in plaintext (no field-level
|
||||
// encryption). This is what lets the unauthenticated resolver serve them.
|
||||
publicKey: text("public_key", { length: 8192 }).notNull(),
|
||||
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
|
||||
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
|
||||
keyType: text("key_type").notNull(),
|
||||
algorithm: text("algorithm").notNull(),
|
||||
label: text("label"),
|
||||
comment: text("comment"),
|
||||
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
|
||||
source: text("source").notNull().default("manual"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// Per-identity certificate authority. Servers that trust this CA (via
|
||||
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
|
||||
// giving central revocation (rotate the CA) and expiry (cert validity).
|
||||
export const termixIdentityCa = sqliteTable("termix_identity_ca", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identityId: integer("identity_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => termixIdentities.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// CA public key (plaintext — it is published); CA private key is field-encrypted.
|
||||
publicKey: text("public_key", { length: 4096 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
validityDays: integer("validity_days").notNull().default(90),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- termix-id end ---
|
||||
|
||||
// --- tmux-monitor begin ---
|
||||
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
@@ -804,3 +963,118 @@ export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- tmux-monitor end ---
|
||||
|
||||
// --- metrics-history begin ---
|
||||
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ts: text("ts")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
cpuPercent: real("cpu_percent"),
|
||||
memPercent: real("mem_percent"),
|
||||
diskPercent: real("disk_percent"),
|
||||
netRxBytes: integer("net_rx_bytes"),
|
||||
netTxBytes: integer("net_tx_bytes"),
|
||||
});
|
||||
// --- metrics-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
export const alertRules = sqliteTable("alert_rules", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
thresholdValue: real("threshold_value"),
|
||||
thresholdDurationSeconds: integer("threshold_duration_seconds"),
|
||||
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const notificationChannels = sqliteTable("notification_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const alertRuleChannels = sqliteTable("alert_rule_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const alertFirings = sqliteTable("alert_firings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").notNull(),
|
||||
hostName: text("host_name").notNull(),
|
||||
firedAt: text("fired_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
resolvedAt: text("resolved_at"),
|
||||
value: real("value"),
|
||||
message: text("message").notNull(),
|
||||
severity: text("severity").notNull().default("warning"),
|
||||
acknowledged: integer("acknowledged", { mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = sqliteTable("homepage_items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
typeId: text("type_id").notNull(),
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
@@ -12,6 +12,10 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
|
||||
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
|
||||
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
|
||||
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
|
||||
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
|
||||
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
|
||||
DATA_DIR,
|
||||
"ssl",
|
||||
@@ -270,6 +274,15 @@ export function registerAcmeSSLRoutes(
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
await fs.mkdir(ACME_WEBROOT, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
||||
|
||||
const certbotDirFlags = [
|
||||
`--config-dir "${CERTBOT_CONFIG_DIR}"`,
|
||||
`--work-dir "${CERTBOT_WORK_DIR}"`,
|
||||
`--logs-dir "${CERTBOT_LOGS_DIR}"`,
|
||||
].join(" ");
|
||||
|
||||
let certbotCmd: string;
|
||||
|
||||
@@ -304,6 +317,7 @@ export function registerAcmeSSLRoutes(
|
||||
`"${email}"`,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
certbotDirFlags,
|
||||
].join(" ");
|
||||
} else {
|
||||
certbotCmd = [
|
||||
@@ -320,6 +334,7 @@ export function registerAcmeSSLRoutes(
|
||||
`"${email}"`,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
certbotDirFlags,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -331,7 +346,7 @@ export function registerAcmeSSLRoutes(
|
||||
|
||||
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
|
||||
|
||||
const liveDir = `/etc/letsencrypt/live/termix`;
|
||||
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
|
||||
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
||||
const privkeySrc = path.join(liveDir, "privkey.pem");
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../db/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const VALID_TRIGGER_TYPES = new Set([
|
||||
"host_offline",
|
||||
"host_online",
|
||||
"cpu_threshold",
|
||||
"memory_threshold",
|
||||
"disk_threshold",
|
||||
"health_check_failure",
|
||||
"health_check_recovery",
|
||||
"user_login",
|
||||
]);
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// ---- Notification Channels ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.all(userId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/notification-channels", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy") {
|
||||
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = getDb()
|
||||
.$client.prepare(
|
||||
`INSERT INTO notification_channels (user_id, name, type, config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
name.trim(),
|
||||
type,
|
||||
JSON.stringify(config),
|
||||
enabled !== false ? 1 : 0,
|
||||
);
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
|
||||
.get(result.lastInsertRowid);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_created");
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create notification channel", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/notification-channels/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const existing = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy") {
|
||||
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(name.trim());
|
||||
}
|
||||
if (type !== undefined) {
|
||||
updates.push("type = ?");
|
||||
params.push(type);
|
||||
}
|
||||
if (config !== undefined) {
|
||||
updates.push("config = ?");
|
||||
params.push(JSON.stringify(config));
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
updates.push("enabled = ?");
|
||||
params.push(enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.json({ success: true });
|
||||
|
||||
params.push(channelId, userId);
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
|
||||
)
|
||||
.run(...params);
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
|
||||
.get(channelId);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_updated");
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update notification channel", {
|
||||
operation: "update_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/notification-channels/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const existing = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
getDb()
|
||||
.$client.prepare("DELETE FROM notification_channels WHERE id = ?")
|
||||
.run(channelId);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId) as { type: string; config: string } | undefined;
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Alert Rules ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* get:
|
||||
* summary: List alert rules for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-rules", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rules = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.all(userId) as Array<{ id: number }>;
|
||||
|
||||
const channelMap = new Map<number, number[]>();
|
||||
for (const rule of rules) {
|
||||
const channels = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
|
||||
)
|
||||
.all(rule.id) as Array<{ channel_id: number }>;
|
||||
channelMap.set(
|
||||
rule.id,
|
||||
channels.map((c) => c.channel_id),
|
||||
);
|
||||
}
|
||||
|
||||
const result = rules.map((r) => ({
|
||||
...r,
|
||||
channels: channelMap.get(r.id) ?? [],
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert rules", {
|
||||
operation: "list_alert_rules",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert rules" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* post:
|
||||
* summary: Create an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-rules", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels = [],
|
||||
} = req.body as {
|
||||
name: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdValue must be between 0 and 100" });
|
||||
}
|
||||
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdDurationSeconds must be >= 0" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = getDb()
|
||||
.$client.prepare(
|
||||
`INSERT INTO alert_rules
|
||||
(user_id, host_id, name, enabled, trigger_type, threshold_value,
|
||||
threshold_duration_seconds, cooldown_minutes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
hostId ?? null,
|
||||
name.trim(),
|
||||
enabled !== false ? 1 : 0,
|
||||
triggerType,
|
||||
thresholdValue ?? null,
|
||||
thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes ?? 15,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const ruleId = result.lastInsertRowid as number;
|
||||
|
||||
for (const channelId of channels) {
|
||||
const owned = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!owned) continue;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
|
||||
)
|
||||
.run(ruleId, channelId);
|
||||
}
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
|
||||
.get(ruleId) as Record<string, unknown>;
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_created");
|
||||
res.status(201).json({ ...row, channels });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create alert rule", {
|
||||
operation: "create_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* put:
|
||||
* summary: Update an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/alert-rules/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
|
||||
const existing = getDb()
|
||||
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
|
||||
.get(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: string[] = ["updated_at = ?"];
|
||||
const params: unknown[] = [new Date().toISOString()];
|
||||
if (name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(name.trim());
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
updates.push("host_id = ?");
|
||||
params.push(hostId ?? null);
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
updates.push("enabled = ?");
|
||||
params.push(enabled ? 1 : 0);
|
||||
}
|
||||
if (triggerType !== undefined) {
|
||||
updates.push("trigger_type = ?");
|
||||
params.push(triggerType);
|
||||
}
|
||||
if (thresholdValue !== undefined) {
|
||||
updates.push("threshold_value = ?");
|
||||
params.push(thresholdValue ?? null);
|
||||
}
|
||||
if (thresholdDurationSeconds !== undefined) {
|
||||
updates.push("threshold_duration_seconds = ?");
|
||||
params.push(thresholdDurationSeconds ?? null);
|
||||
}
|
||||
if (cooldownMinutes !== undefined) {
|
||||
updates.push("cooldown_minutes = ?");
|
||||
params.push(cooldownMinutes);
|
||||
}
|
||||
params.push(ruleId, userId);
|
||||
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
|
||||
)
|
||||
.run(...params);
|
||||
|
||||
if (channels !== undefined) {
|
||||
getDb()
|
||||
.$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?")
|
||||
.run(ruleId);
|
||||
for (const channelId of channels) {
|
||||
const owned = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!owned) continue;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
|
||||
)
|
||||
.run(ruleId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
|
||||
.get(ruleId) as Record<string, unknown>;
|
||||
const linkedChannels = (
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
|
||||
)
|
||||
.all(ruleId) as Array<{ channel_id: number }>
|
||||
).map((c) => c.channel_id);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_updated");
|
||||
res.json({ ...row, channels: linkedChannels });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update alert rule", {
|
||||
operation: "update_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* delete:
|
||||
* summary: Delete an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/alert-rules/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
const existing = getDb()
|
||||
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
|
||||
.get(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Alert Firings ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings:
|
||||
* get:
|
||||
* summary: List alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-firings", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const acknowledgedParam = req.query.acknowledged;
|
||||
|
||||
try {
|
||||
let whereClause = "af.user_id = ?";
|
||||
const params: unknown[] = [userId];
|
||||
|
||||
if (acknowledgedParam === "true") {
|
||||
whereClause += " AND af.acknowledged = 1";
|
||||
} else if (acknowledgedParam === "false") {
|
||||
whereClause += " AND af.acknowledged = 0";
|
||||
}
|
||||
|
||||
const rows = getDb()
|
||||
.$client.prepare(
|
||||
`SELECT af.*, ar.name as rule_name
|
||||
FROM alert_firings af
|
||||
LEFT JOIN alert_rules ar ON ar.id = af.rule_id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY af.fired_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, limit, offset);
|
||||
|
||||
const total = (
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`,
|
||||
)
|
||||
.get(...params) as { c: number }
|
||||
).c;
|
||||
|
||||
res.json({ firings: rows, total });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert firings", {
|
||||
operation: "list_alert_firings",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert firings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/{id}/acknowledge:
|
||||
* post:
|
||||
* summary: Acknowledge an alert firing
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const firingId = Number(req.params.id);
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.run(firingId, userId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/acknowledge-all:
|
||||
* post:
|
||||
* summary: Acknowledge all alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/acknowledge-all", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?",
|
||||
)
|
||||
.run(userId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,6 +7,8 @@ import { eq } from "drizzle-orm";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { db } from "../db/index.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
|
||||
@@ -263,97 +265,100 @@ async function deploySSHKeyToHost(
|
||||
resolve({ success: false, error: errorMessage });
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
if (
|
||||
!privateKey.includes("-----BEGIN") ||
|
||||
!privateKey.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
connectionConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
privateKey,
|
||||
hostConfig.keyPassword as string | undefined,
|
||||
);
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = privateKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
} else if (hostConfig.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
connectionConfig,
|
||||
hostConfig.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({ success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
} catch (keyError) {
|
||||
} else {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +484,7 @@ export function registerCredentialDeployRoutes(
|
||||
password: hostData.password,
|
||||
privateKey: hostData.key,
|
||||
keyPassword: hostData.keyPassword,
|
||||
terminalConfig: hostData.terminalConfig,
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
|
||||
@@ -137,8 +137,7 @@ router.post(
|
||||
.status(400)
|
||||
.json({ error: "SSH key is required for key authentication" });
|
||||
}
|
||||
const plainPassword =
|
||||
authType === "password" && password ? password : null;
|
||||
const plainPassword = password ? password : null;
|
||||
const plainKey = authType === "key" && key ? key : null;
|
||||
const plainKeyPassword =
|
||||
authType === "key" && keyPassword ? keyPassword : null;
|
||||
@@ -156,7 +155,7 @@ router.post(
|
||||
return res.status(400).json({
|
||||
error: keyInfo.error
|
||||
? `Invalid SSH key: ${keyInfo.error}`
|
||||
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
|
||||
: "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -514,10 +513,11 @@ router.put(
|
||||
if (updateData.password !== undefined) {
|
||||
updateFields.password = updateData.password || null;
|
||||
}
|
||||
const nextAuthType = updateData.authType ?? existing[0].authType;
|
||||
if (updateData.key !== undefined) {
|
||||
updateFields.key = updateData.key || null;
|
||||
|
||||
if (updateData.key && existing[0].authType === "key") {
|
||||
if (updateData.key && nextAuthType === "key") {
|
||||
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||
if (!keyInfo.success) {
|
||||
authLogger.warn("SSH key parsing failed during update", {
|
||||
@@ -529,7 +529,7 @@ router.put(
|
||||
return res.status(400).json({
|
||||
error: keyInfo.error
|
||||
? `Invalid SSH key: ${keyInfo.error}`
|
||||
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
|
||||
: "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
updateFields.privateKey = keyInfo.privateKey;
|
||||
|
||||
@@ -5,19 +5,14 @@ import { dashboardLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "./service-link-url.js";
|
||||
import express from "express";
|
||||
|
||||
export const dashboardServiceLinksRouter = express.Router();
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /service-links:
|
||||
@@ -84,7 +79,8 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
|
||||
return res.status(400).json({ error: "label and url are required" });
|
||||
}
|
||||
if (!isValidUrl(url)) {
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
@@ -105,7 +101,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||
.values({
|
||||
userId,
|
||||
label: label.trim(),
|
||||
url: url.trim(),
|
||||
url: normalizedUrl,
|
||||
order: nextOrder,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
@@ -233,7 +229,10 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: "Invalid id" });
|
||||
}
|
||||
if (url !== undefined && !isValidUrl(url)) {
|
||||
const normalizedUrl = isNonEmptyString(url)
|
||||
? normalizeServiceLinkUrl(url)
|
||||
: undefined;
|
||||
if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "url must be a valid http or https URL" });
|
||||
@@ -256,7 +255,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
|
||||
const updates: Partial<{ label: string; url: string }> = {};
|
||||
if (isNonEmptyString(label)) updates.label = label.trim();
|
||||
if (isNonEmptyString(url)) updates.url = url.trim();
|
||||
if (normalizedUrl !== undefined) updates.url = normalizedUrl;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({ error: "Nothing to update" });
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
export const homepageFaviconRouter = express.Router();
|
||||
|
||||
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
|
||||
const faviconCache = new Map<
|
||||
string,
|
||||
{ data: Buffer; contentType: string; expires: number }
|
||||
>();
|
||||
const CACHE_SIZE = 100;
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
function evictIfNeeded() {
|
||||
if (faviconCache.size >= CACHE_SIZE) {
|
||||
const oldest = faviconCache.keys().next().value;
|
||||
if (oldest) faviconCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
data: Buffer.concat(chunks),
|
||||
contentType: res.headers["content-type"] || "image/x-icon",
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Favicon fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/favicon:
|
||||
* get:
|
||||
* summary: Proxy favicon fetch
|
||||
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Favicon image.
|
||||
* 400:
|
||||
* description: Invalid URL.
|
||||
* 500:
|
||||
* description: Failed to fetch favicon.
|
||||
*/
|
||||
homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
|
||||
const rawUrl = req.query.url as string;
|
||||
if (!rawUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
let domain: string;
|
||||
try {
|
||||
domain = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = faviconCache.get(domain);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
res.setHeader("Content-Type", cached.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.send(cached.data);
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchUrl(faviconUrl);
|
||||
evictIfNeeded();
|
||||
faviconCache.set(domain, {
|
||||
data,
|
||||
contentType,
|
||||
expires: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.send(data);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Failed to fetch favicon", { domain });
|
||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* get:
|
||||
* summary: Get homepage items
|
||||
* description: Returns all homepage widget items for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of homepage items.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage items.
|
||||
*/
|
||||
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const items = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.orderBy(asc(homepageItems.id));
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage items", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage items" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* post:
|
||||
* summary: Create homepage item
|
||||
* description: Creates a new homepage widget item for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - typeId
|
||||
* properties:
|
||||
* typeId:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Homepage item created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create homepage item.
|
||||
*/
|
||||
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { typeId, title, config } = req.body;
|
||||
|
||||
if (!typeId || typeof typeId !== "string") {
|
||||
return res.status(400).json({ error: "typeId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await db
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
userId,
|
||||
typeId,
|
||||
title: title ?? null,
|
||||
config: config ? JSON.stringify(config) : "{}",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_created");
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to create homepage item", err);
|
||||
res.status(500).json({ error: "Failed to create homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* put:
|
||||
* summary: Update homepage item
|
||||
* description: Updates a homepage widget item.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update homepage item.
|
||||
*/
|
||||
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { title, config } = req.body;
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const updates: Partial<{
|
||||
title: string | null;
|
||||
config: string;
|
||||
updatedAt: string;
|
||||
}> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (config !== undefined) updates.config = JSON.stringify(config);
|
||||
|
||||
const [updated] = await db
|
||||
.update(homepageItems)
|
||||
.set(updates)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_updated");
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to update homepage item", err);
|
||||
res.status(500).json({ error: "Failed to update homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete homepage item
|
||||
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete homepage item.
|
||||
*/
|
||||
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_deleted");
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
res.status(500).json({ error: "Failed to delete homepage item" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageLayoutRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* get:
|
||||
* summary: Get homepage layout
|
||||
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout data or null.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
const parsed = JSON.parse(row.layout || "{}");
|
||||
res.json({ ...row, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* put:
|
||||
* summary: Save homepage layout
|
||||
* description: Saves or updates the homepage canvas layout for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* entries:
|
||||
* type: array
|
||||
* pan:
|
||||
* type: object
|
||||
* zoom:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 500:
|
||||
* description: Failed to save homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const layoutData = req.body;
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select({ id: homepageLayouts.id })
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
const layoutJson = JSON.stringify(layoutData);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing.length === 0) {
|
||||
const [created] = await db
|
||||
.insert(homepageLayouts)
|
||||
.values({ userId, layout: layoutJson, updatedAt: now })
|
||||
.returning();
|
||||
const parsed = JSON.parse(created.layout);
|
||||
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
|
||||
return res.json({ ...created, layout: parsed });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(homepageLayouts)
|
||||
.set({ layout: layoutJson, updatedAt: now })
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning();
|
||||
|
||||
const parsed = JSON.parse(updated.layout);
|
||||
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
|
||||
res.json({ ...updated, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to save homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to save homepage layout" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepagePingRouter = express.Router();
|
||||
|
||||
interface PingCacheEntry {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const pingCache = new Map<string, PingCacheEntry>();
|
||||
const CACHE_SIZE = 200;
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
function pingUrl(
|
||||
url: string,
|
||||
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
|
||||
const done = (ok: boolean, statusCode: number | null) => {
|
||||
resolve({
|
||||
ok,
|
||||
statusCode,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
});
|
||||
};
|
||||
|
||||
const tryGet = () => {
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
done(code !== null && code < 400, code);
|
||||
});
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
};
|
||||
|
||||
const req = mod.request(
|
||||
url,
|
||||
{ method: "HEAD", timeout: FETCH_TIMEOUT_MS },
|
||||
(res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
if (code === 405) {
|
||||
tryGet();
|
||||
} else {
|
||||
done(code !== null && code < 400, code);
|
||||
}
|
||||
},
|
||||
);
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/ping:
|
||||
* get:
|
||||
* summary: Check the HTTP reachability and latency of a URL
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ping result with ok, statusCode and latencyMs.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
*/
|
||||
homepagePingRouter.get("/", async (req: Request, res: Response) => {
|
||||
let targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = pingCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json({
|
||||
ok: cached.ok,
|
||||
statusCode: cached.statusCode,
|
||||
latencyMs: cached.latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pingUrl(targetUrl);
|
||||
if (pingCache.size >= CACHE_SIZE) {
|
||||
const oldest = pingCache.keys().next().value;
|
||||
if (oldest) pingCache.delete(oldest);
|
||||
}
|
||||
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Ping failed", { targetUrl });
|
||||
res.status(500).json({ error: "Ping failed" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepageProxyRouter = express.Router();
|
||||
|
||||
interface ProxyCacheEntry {
|
||||
data: unknown;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const proxyCache = new Map<string, ProxyCacheEntry>();
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
function fetchJson(url: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new Error("Response is not valid JSON"));
|
||||
}
|
||||
});
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/proxy:
|
||||
* get:
|
||||
* summary: Proxy a JSON API URL and return the parsed response
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10, default 60)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The JSON body returned by the target URL.
|
||||
* 400:
|
||||
* description: Invalid or missing URL, or non-JSON response.
|
||||
* 500:
|
||||
* description: Failed to fetch the target URL.
|
||||
*/
|
||||
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||
const targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = proxyCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchJson(targetUrl);
|
||||
if (proxyCache.size >= CACHE_SIZE) {
|
||||
const oldest = proxyCache.keys().next().value;
|
||||
if (oldest) proxyCache.delete(oldest);
|
||||
}
|
||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
|
||||
if (msg.includes("not valid JSON")) {
|
||||
return res.status(400).json({ error: "Response is not valid JSON" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to fetch URL" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepageRssRouter = express.Router();
|
||||
|
||||
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
||||
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function fetchXml(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("RSS fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const getText = (tag: string, src: string): string | null => {
|
||||
const m = new RegExp(
|
||||
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
||||
"i",
|
||||
).exec(src);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2]).trim();
|
||||
};
|
||||
|
||||
const getLink = (src: string): string => {
|
||||
// Self-closing <link rel="alternate" href="..." /> (BBC style)
|
||||
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
|
||||
if (selfClose) return selfClose[1];
|
||||
// Plain text <link>url</link>
|
||||
return getText("link", src) ?? "";
|
||||
};
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: getLink(src),
|
||||
pubDate: getText("pubDate", src) ?? getText("updated", src),
|
||||
description: getText("description", src) ?? getText("summary", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
|
||||
// Atom feed fallback
|
||||
if (items.length === 0) {
|
||||
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: linkMatch?.[1] ?? "",
|
||||
pubDate: getText("published", src) ?? getText("updated", src),
|
||||
description: getText("summary", src) ?? getText("content", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/rss:
|
||||
* get:
|
||||
* summary: Proxy and parse an RSS/Atom feed
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: max
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of feed items.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
* 500:
|
||||
* description: Failed to fetch or parse the feed.
|
||||
*/
|
||||
homepageRssRouter.get("/", async (req: Request, res: Response) => {
|
||||
const feedUrl = req.query.url as string;
|
||||
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
|
||||
|
||||
if (!feedUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = rssCache.get(feedUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data.slice(0, max));
|
||||
}
|
||||
|
||||
try {
|
||||
const xml = await fetchXml(feedUrl);
|
||||
const items = parseRss(xml);
|
||||
|
||||
if (rssCache.size >= CACHE_SIZE) {
|
||||
const oldest = rssCache.keys().next().value;
|
||||
if (oldest) rssCache.delete(oldest);
|
||||
}
|
||||
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
||||
|
||||
res.json(items.slice(0, max));
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
||||
res.status(500).json({ error: "Failed to fetch feed" });
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,35 @@ type SSHConfigHost = {
|
||||
proxyJump?: string;
|
||||
};
|
||||
|
||||
type ShareCredential = {
|
||||
alias?: unknown;
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
folder?: unknown;
|
||||
tags?: unknown;
|
||||
authType?: unknown;
|
||||
username?: unknown;
|
||||
keyType?: unknown;
|
||||
};
|
||||
|
||||
function textValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function tagString(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tag) => textValue(tag))
|
||||
.filter((tag): tag is string => !!tag)
|
||||
.join(",");
|
||||
}
|
||||
return textValue(value) || "";
|
||||
}
|
||||
|
||||
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
||||
return value === "key" ? "key" : "password";
|
||||
}
|
||||
|
||||
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
const results: SSHConfigHost[] = [];
|
||||
let current: SSHConfigHost | null = null;
|
||||
@@ -280,7 +309,11 @@ export function registerHostBulkRoutes(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hosts: hostsToImport, overwrite } = req.body;
|
||||
const {
|
||||
hosts: hostsToImport,
|
||||
overwrite,
|
||||
credentials: credentialsToImport,
|
||||
} = req.body;
|
||||
|
||||
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||
return res
|
||||
@@ -302,6 +335,80 @@ export function registerHostBulkRoutes(
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
const credentialAliasMap = new Map<string, number>();
|
||||
const addCredentialAlias = (alias: unknown, id: number) => {
|
||||
const key = textValue(alias);
|
||||
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
||||
};
|
||||
|
||||
try {
|
||||
const existingCredentials = await SimpleDBOps.select<
|
||||
Record<string, unknown>
|
||||
>(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
for (const credential of existingCredentials) {
|
||||
addCredentialAlias(credential.name, credential.id as number);
|
||||
}
|
||||
|
||||
if (Array.isArray(credentialsToImport)) {
|
||||
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
||||
const alias = textValue(rawCredential.alias);
|
||||
const name = textValue(rawCredential.name) || alias;
|
||||
if (!alias || !name) continue;
|
||||
|
||||
const existingId = credentialAliasMap.get(name.toLowerCase());
|
||||
if (existingId) {
|
||||
addCredentialAlias(alias, existingId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created = await SimpleDBOps.insert(
|
||||
sshCredentials,
|
||||
"ssh_credentials",
|
||||
{
|
||||
userId,
|
||||
name,
|
||||
description:
|
||||
textValue(rawCredential.description) ||
|
||||
"Imported placeholder. Add the secret before connecting.",
|
||||
folder: textValue(rawCredential.folder),
|
||||
tags: tagString(rawCredential.tags),
|
||||
authType: normalizeCredentialAuthType(rawCredential.authType),
|
||||
username: textValue(rawCredential.username),
|
||||
password: null,
|
||||
key: null,
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyPassword: null,
|
||||
keyType: textValue(rawCredential.keyType),
|
||||
detectedKeyType: null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const createdCredential = created as Record<string, unknown>;
|
||||
addCredentialAlias(alias, createdCredential.id as number);
|
||||
addCredentialAlias(name, createdCredential.id as number);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(
|
||||
`Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`,
|
||||
);
|
||||
}
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
if (overwrite) {
|
||||
try {
|
||||
@@ -326,6 +433,17 @@ export function registerHostBulkRoutes(
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
!hostData.credentialId &&
|
||||
hostData.credentialAlias
|
||||
) {
|
||||
hostData.credentialId = credentialAliasMap.get(
|
||||
hostData.credentialAlias.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
@@ -355,11 +473,12 @@ export function registerHostBulkRoutes(
|
||||
"none",
|
||||
"opkssh",
|
||||
"tailscale",
|
||||
"vault",
|
||||
].includes(hostData.authType)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', or 'tailscale'`,
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,16 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.credentialId).toBe(7);
|
||||
expect(host.authType).toBe("credential");
|
||||
});
|
||||
|
||||
it("infers credential auth from share aliases", () => {
|
||||
const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
|
||||
expect(aliasHost.credentialAlias).toBe("prod-admin");
|
||||
expect(aliasHost.authType).toBe("credential");
|
||||
|
||||
const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
|
||||
expect(nameHost.credentialAlias).toBe("ops-key");
|
||||
expect(nameHost.authType).toBe("credential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSensitiveFields", () => {
|
||||
|
||||
@@ -94,6 +94,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
credentialAlias?: string;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
@@ -138,6 +139,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
export function normalizeImportedHost(
|
||||
hostData: Record<string, unknown>,
|
||||
): NormalizedImportedHost {
|
||||
const credentialAlias =
|
||||
asString(hostData.credentialAlias) || asString(hostData.credentialName);
|
||||
const connectionType =
|
||||
asString(hostData.connectionType) ||
|
||||
(asBoolean(hostData.enableRdp)
|
||||
@@ -172,10 +175,15 @@ export function normalizeImportedHost(
|
||||
folder: asString(hostData.folder) || asString(hostData.group),
|
||||
tags: normalizeImportTags(hostData.tags),
|
||||
credentialId: asInteger(hostData.credentialId),
|
||||
credentialAlias,
|
||||
authType:
|
||||
asString(hostData.authType) ||
|
||||
asString(hostData.authMethod) ||
|
||||
(hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
|
||||
(hostData.credentialId || credentialAlias
|
||||
? "credential"
|
||||
: hostData.key
|
||||
? "key"
|
||||
: undefined),
|
||||
enableSsh:
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
|
||||
@@ -79,6 +79,69 @@ const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
type ShareCredentialExport = {
|
||||
alias: string;
|
||||
name: string;
|
||||
authType: "password" | "key";
|
||||
username: string | null;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
tags: string[];
|
||||
keyType: string | null;
|
||||
};
|
||||
|
||||
function parseJsonField(value: unknown, fallback: unknown) {
|
||||
if (!value || typeof value !== "string") return fallback;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function splitTags(value: unknown): string[] {
|
||||
if (Array.isArray(value))
|
||||
return value.filter((tag) => typeof tag === "string");
|
||||
if (typeof value !== "string") return [];
|
||||
return value.split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
function uniqueAlias(base: string, used: Set<string>): string {
|
||||
const normalized =
|
||||
base
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "credential";
|
||||
let alias = normalized;
|
||||
let suffix = 2;
|
||||
while (used.has(alias)) {
|
||||
alias = `${normalized}-${suffix++}`;
|
||||
}
|
||||
used.add(alias);
|
||||
return alias;
|
||||
}
|
||||
|
||||
function safeCredentialExport(
|
||||
credential: Record<string, unknown>,
|
||||
alias: string,
|
||||
): ShareCredentialExport {
|
||||
return {
|
||||
alias,
|
||||
name: String(credential.name || alias),
|
||||
authType: credential.authType === "key" ? "key" : "password",
|
||||
username:
|
||||
typeof credential.username === "string" ? credential.username : null,
|
||||
description:
|
||||
typeof credential.description === "string"
|
||||
? credential.description
|
||||
: null,
|
||||
folder: typeof credential.folder === "string" ? credential.folder : null,
|
||||
tags: splitTags(credential.tags),
|
||||
keyType: typeof credential.keyType === "string" ? credential.keyType : null,
|
||||
};
|
||||
}
|
||||
|
||||
registerHostInternalRoutes(router);
|
||||
|
||||
/**
|
||||
@@ -146,6 +209,7 @@ router.post(
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -200,6 +264,8 @@ router.post(
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetUser,
|
||||
@@ -248,6 +314,7 @@ router.post(
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -322,6 +389,11 @@ router.post(
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
@@ -339,19 +411,6 @@ router.post(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
|
||||
sshLogger.warn("Invalid SSH key format provided", {
|
||||
operation: "host_create",
|
||||
userId,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid SSH key format. Key must be in PEM format.",
|
||||
});
|
||||
}
|
||||
|
||||
const keyValidation = parseSSHKey(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
@@ -375,6 +434,11 @@ router.post(
|
||||
sshDataObj.keyPassword = keyPassword || null;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -721,6 +785,7 @@ router.put(
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -775,6 +840,8 @@ router.put(
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetUser,
|
||||
@@ -820,6 +887,7 @@ router.put(
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -894,6 +962,11 @@ router.put(
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
@@ -915,20 +988,6 @@ router.put(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
|
||||
sshLogger.warn("Invalid SSH key format provided", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid SSH key format. Key must be in PEM format.",
|
||||
});
|
||||
}
|
||||
|
||||
const keyValidation = parseSSHKey(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
@@ -957,6 +1016,11 @@ router.put(
|
||||
sshDataObj.keyType = keyType;
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -1209,6 +1273,7 @@ router.get(
|
||||
createdAt: hosts.createdAt,
|
||||
updatedAt: hosts.updatedAt,
|
||||
credentialId: hosts.credentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
overrideCredentialUsername: hosts.overrideCredentialUsername,
|
||||
quickActions: hosts.quickActions,
|
||||
notes: hosts.notes,
|
||||
@@ -1249,6 +1314,7 @@ router.get(
|
||||
rdpDomain: hosts.rdpDomain,
|
||||
rdpSecurity: hosts.rdpSecurity,
|
||||
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
||||
vncAuthType: hosts.vncAuthType,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
@@ -1444,7 +1510,7 @@ router.get(
|
||||
* name: field
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [password, sudoPassword]
|
||||
* enum: [password, sudoPassword, vncPassword]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested password value.
|
||||
@@ -1460,7 +1526,7 @@ router.get(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (!["password", "sudoPassword"].includes(field)) {
|
||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
@@ -1599,6 +1665,8 @@ router.get(
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
rdpSecurity: resolvedHost.rdpSecurity || null,
|
||||
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
|
||||
vncAuthType: resolvedHost.vncAuthType || null,
|
||||
vncCredentialId: resolvedHost.vncCredentialId || null,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
vncPassword: resolvedHost.vncPassword || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
@@ -1706,6 +1774,11 @@ router.get(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const shareExport =
|
||||
req.query.share === "1" ||
|
||||
req.query.share === "true" ||
|
||||
req.query.safe === "1" ||
|
||||
req.query.safe === "true";
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
@@ -1718,6 +1791,192 @@ router.get(
|
||||
userId,
|
||||
);
|
||||
|
||||
if (shareExport) {
|
||||
const credentials = await SimpleDBOps.select<Record<string, unknown>>(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
const credentialsById = new Map<number, Record<string, unknown>>();
|
||||
for (const credential of credentials) {
|
||||
if (typeof credential.id === "number") {
|
||||
credentialsById.set(credential.id, credential);
|
||||
}
|
||||
}
|
||||
|
||||
const usedAliases = new Set<string>();
|
||||
const exportedCredentials = new Map<string, ShareCredentialExport>();
|
||||
const credentialIdAliases = new Map<number, string>();
|
||||
const directCredentialAliases = new Map<string, string>();
|
||||
|
||||
const addCredential = (
|
||||
credential: Record<string, unknown>,
|
||||
fallbackName: string,
|
||||
) => {
|
||||
if (typeof credential.id === "number") {
|
||||
const existing = credentialIdAliases.get(credential.id);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const alias = uniqueAlias(
|
||||
String(credential.name || fallbackName),
|
||||
usedAliases,
|
||||
);
|
||||
exportedCredentials.set(
|
||||
alias,
|
||||
safeCredentialExport(credential, alias),
|
||||
);
|
||||
if (typeof credential.id === "number") {
|
||||
credentialIdAliases.set(credential.id, alias);
|
||||
}
|
||||
return alias;
|
||||
};
|
||||
|
||||
const addDirectCredential = (
|
||||
authType: "password" | "key",
|
||||
username: unknown,
|
||||
keyType?: unknown,
|
||||
) => {
|
||||
const usernameText =
|
||||
typeof username === "string" && username.trim()
|
||||
? username.trim()
|
||||
: "user";
|
||||
const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`;
|
||||
const existing = directCredentialAliases.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases);
|
||||
directCredentialAliases.set(key, alias);
|
||||
exportedCredentials.set(
|
||||
alias,
|
||||
safeCredentialExport(
|
||||
{
|
||||
name: `${usernameText} ${authType}`,
|
||||
authType,
|
||||
username: usernameText,
|
||||
keyType,
|
||||
},
|
||||
alias,
|
||||
),
|
||||
);
|
||||
return alias;
|
||||
};
|
||||
|
||||
const exportedHosts = allHosts.map((host) => {
|
||||
const exportedConnectionType =
|
||||
(host.connectionType as string) || "ssh";
|
||||
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
|
||||
exportedConnectionType,
|
||||
);
|
||||
|
||||
const baseExportData: Record<string, unknown> = {
|
||||
connectionType: exportedConnectionType,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: splitTags(host.tags),
|
||||
notes: host.notes || null,
|
||||
};
|
||||
|
||||
if (isRemoteDesktop) {
|
||||
return {
|
||||
...baseExportData,
|
||||
domain: host.domain || null,
|
||||
security: host.security || null,
|
||||
ignoreCert: !!host.ignoreCert,
|
||||
guacamoleConfig: parseJsonField(host.guacamoleConfig, null),
|
||||
};
|
||||
}
|
||||
|
||||
const exportData: Record<string, unknown> = {
|
||||
...baseExportData,
|
||||
authType: host.authType || "none",
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableDocker: !!host.enableDocker,
|
||||
enableProxmox: !!host.enableProxmox,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
defaultPath: host.defaultPath,
|
||||
tunnelConnections: parseJsonField(host.tunnelConnections, []),
|
||||
jumpHosts: parseJsonField(host.jumpHosts, null),
|
||||
quickActions: parseJsonField(host.quickActions, null),
|
||||
statsConfig: parseJsonField(host.statsConfig, null),
|
||||
dockerConfig: parseJsonField(host.dockerConfig, null),
|
||||
proxmoxConfig: parseJsonField(host.proxmoxConfig, null),
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useSocks5: !!host.useSocks5,
|
||||
socks5Host: host.socks5Host || null,
|
||||
socks5Port: host.socks5Port || null,
|
||||
socks5Username: host.socks5Username || null,
|
||||
socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null),
|
||||
portKnockSequence: parseJsonField(host.portKnockSequence, null),
|
||||
overrideCredentialUsername: !!host.overrideCredentialUsername,
|
||||
};
|
||||
|
||||
if (typeof host.credentialId === "number") {
|
||||
const credential = credentialsById.get(host.credentialId);
|
||||
if (credential) {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addCredential(
|
||||
credential,
|
||||
String(host.username || "credential"),
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authType === "password") {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addDirectCredential(
|
||||
"password",
|
||||
host.username,
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
|
||||
if (host.authType === "key") {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addDirectCredential(
|
||||
"key",
|
||||
host.username,
|
||||
host.keyType,
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
|
||||
if (host.authType === "credential") {
|
||||
exportData.authType = "none";
|
||||
}
|
||||
|
||||
return exportData;
|
||||
});
|
||||
|
||||
sshLogger.success("All hosts exported for sharing", {
|
||||
operation: "hosts_export_share",
|
||||
count: exportedHosts.length,
|
||||
credentialCount: exportedCredentials.size,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
version: "termix-host-share-v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials: Array.from(exportedCredentials.values()),
|
||||
hosts: exportedHosts,
|
||||
});
|
||||
}
|
||||
|
||||
const exportedHosts = [];
|
||||
|
||||
for (const host of allHosts) {
|
||||
|
||||
@@ -357,6 +357,16 @@ router.post(
|
||||
sshConfig.privateKey = resolvedCredentials.sshKey;
|
||||
if (resolvedCredentials.keyPassword)
|
||||
sshConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
} else if (authType === "agent") {
|
||||
const { applyAgentAuth } =
|
||||
await import("../../ssh/terminal-auth-helpers.js");
|
||||
const result = await applyAgentAuth(
|
||||
sshConfig,
|
||||
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
} else if (resolvedCredentials.password) {
|
||||
sshConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "./service-link-url.js";
|
||||
|
||||
describe("service link URL handling", () => {
|
||||
it("keeps explicit http and https URLs", () => {
|
||||
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds http to bare service addresses", () => {
|
||||
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
|
||||
});
|
||||
|
||||
it("rejects unsupported schemes", () => {
|
||||
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
|
||||
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -773,11 +774,12 @@ router.post(
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
/* eslint-disable no-async-promise-executor */
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}>((resolve, reject) => {
|
||||
}>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error("Command execution timeout (30s)"));
|
||||
@@ -886,6 +888,14 @@ router.post(
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
} else if (authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
host.terminalConfig as Record<string, unknown> | string | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} else if (password) {
|
||||
config.password = password;
|
||||
} else if (privateKey) {
|
||||
@@ -901,6 +911,7 @@ router.post(
|
||||
|
||||
conn.connect(config);
|
||||
});
|
||||
/* eslint-enable no-async-promise-executor */
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
generateCa,
|
||||
signUserCertificate,
|
||||
ed25519RawFromLine,
|
||||
} from "./ssh-certificate.js";
|
||||
|
||||
function publicKeyObjectFromLine(line: string) {
|
||||
const raw = ed25519RawFromLine(line);
|
||||
if (!raw) throw new Error("not ed25519");
|
||||
return crypto.createPublicKey({
|
||||
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
|
||||
format: "jwk",
|
||||
});
|
||||
}
|
||||
|
||||
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
|
||||
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
|
||||
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
|
||||
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
|
||||
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
|
||||
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
|
||||
const rawSig = blob.subarray(blob.length - 64);
|
||||
return { body, rawSig };
|
||||
}
|
||||
|
||||
describe("generateCa", () => {
|
||||
it("produces a valid ed25519 public line and PKCS8 private key", () => {
|
||||
const ca = generateCa();
|
||||
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
|
||||
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
|
||||
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
|
||||
// The PEM must load as a usable signing key.
|
||||
expect(() =>
|
||||
crypto.createPrivateKey({
|
||||
key: ca.privateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUserCertificate", () => {
|
||||
it("returns null for non-ed25519 user keys", () => {
|
||||
const ca = generateCa();
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "x",
|
||||
principals: [],
|
||||
validAfter: 0,
|
||||
validBefore: 1,
|
||||
});
|
||||
expect(cert).toBeNull();
|
||||
});
|
||||
|
||||
it("produces a cert whose signature verifies against the CA key", () => {
|
||||
const ca = generateCa();
|
||||
const user = generateCa(); // reuse: a valid ed25519 public line
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix:@alice",
|
||||
principals: ["root", "ubuntu"],
|
||||
validAfter: 1000,
|
||||
validBefore: 2000,
|
||||
});
|
||||
expect(cert).not.toBeNull();
|
||||
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
|
||||
|
||||
const { body, rawSig } = splitCert(cert!);
|
||||
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
|
||||
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
|
||||
|
||||
// A different CA must NOT verify.
|
||||
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
|
||||
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
|
||||
});
|
||||
|
||||
it("is accepted and correctly parsed by ssh-keygen -L", () => {
|
||||
let sshKeygen: string;
|
||||
try {
|
||||
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
|
||||
void sshKeygen;
|
||||
} catch (e) {
|
||||
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
|
||||
// fine — it means the binary exists. Only skip if it's truly missing.
|
||||
if ((e as { code?: string }).code === "ENOENT") return;
|
||||
}
|
||||
|
||||
const ca = generateCa();
|
||||
const user = generateCa();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix-test-id",
|
||||
principals: ["deploy"],
|
||||
validAfter: now,
|
||||
validBefore: now + 3600,
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
|
||||
const file = path.join(dir, "id-cert.pub");
|
||||
try {
|
||||
fs.writeFileSync(file, cert + "\n");
|
||||
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(out).toContain("user certificate");
|
||||
expect(out).toContain('Key ID: "termix-test-id"');
|
||||
expect(out).toContain("deploy");
|
||||
expect(out).toMatch(/permit-pty/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
|
||||
// Implemented in pure Node so we never shell out or move private keys to disk.
|
||||
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
|
||||
|
||||
const ED25519 = "ssh-ed25519";
|
||||
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
|
||||
const SSH2_CERT_TYPE_USER = 1;
|
||||
|
||||
// Standard login extensions OpenSSH grants by default; must be name-sorted.
|
||||
const DEFAULT_EXTENSIONS = [
|
||||
"permit-X11-forwarding",
|
||||
"permit-agent-forwarding",
|
||||
"permit-port-forwarding",
|
||||
"permit-pty",
|
||||
"permit-user-rc",
|
||||
];
|
||||
|
||||
// --- SSH wire-format primitives -------------------------------------------
|
||||
|
||||
function sshString(value: Buffer | string): Buffer {
|
||||
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length, 0);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
function sshUint32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n >>> 0, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function sshUint64(n: bigint): Buffer {
|
||||
const b = Buffer.alloc(8);
|
||||
b.writeBigUInt64BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ed25519PublicBlob(raw32: Buffer): Buffer {
|
||||
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
|
||||
}
|
||||
|
||||
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
|
||||
export function ed25519RawFromLine(line: string): Buffer | null {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] !== ED25519 || !parts[1]) return null;
|
||||
let blob: Buffer;
|
||||
try {
|
||||
blob = Buffer.from(parts[1], "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let off = 0;
|
||||
const typeLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
|
||||
off += typeLen;
|
||||
const keyLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
const pk = blob.subarray(off, off + keyLen);
|
||||
return pk.length === 32 ? pk : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- CA generation ---------------------------------------------------------
|
||||
|
||||
export interface GeneratedCa {
|
||||
publicKeyLine: string; // "ssh-ed25519 <base64>"
|
||||
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
|
||||
}
|
||||
|
||||
export function generateCa(): GeneratedCa {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const raw = Buffer.from(jwk.x, "base64url");
|
||||
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
|
||||
const privateKeyPem = privateKey
|
||||
.export({ format: "pem", type: "pkcs8" })
|
||||
.toString();
|
||||
return { publicKeyLine, privateKeyPem };
|
||||
}
|
||||
|
||||
// --- Certificate signing ---------------------------------------------------
|
||||
|
||||
export interface SignCertOptions {
|
||||
userPublicKeyLine: string; // ed25519 public key to certify
|
||||
caPrivateKeyPem: string;
|
||||
caPublicKeyLine: string;
|
||||
keyId: string;
|
||||
principals: string[]; // empty = valid for all usernames
|
||||
validAfter: number; // unix seconds
|
||||
validBefore: number; // unix seconds
|
||||
serial?: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an ed25519 user public key into an OpenSSH user certificate.
|
||||
* Returns the certificate line, or null if the inputs aren't ed25519.
|
||||
*/
|
||||
export function signUserCertificate(opts: SignCertOptions): string | null {
|
||||
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
|
||||
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
|
||||
if (!pk || !caRaw) return null;
|
||||
|
||||
const signatureKey = ed25519PublicBlob(caRaw);
|
||||
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
|
||||
const extensions = Buffer.concat(
|
||||
[...DEFAULT_EXTENSIONS]
|
||||
.sort()
|
||||
.map((name) => Buffer.concat([sshString(name), sshString("")])),
|
||||
);
|
||||
|
||||
// Everything up to (not including) the signature — this is what gets signed.
|
||||
const body = Buffer.concat([
|
||||
sshString(CERT_TYPE),
|
||||
sshString(crypto.randomBytes(32)), // nonce
|
||||
sshString(pk),
|
||||
sshUint64(opts.serial ?? 0n),
|
||||
sshUint32(SSH2_CERT_TYPE_USER),
|
||||
sshString(opts.keyId),
|
||||
sshString(principals),
|
||||
sshUint64(BigInt(opts.validAfter)),
|
||||
sshUint64(BigInt(opts.validBefore)),
|
||||
sshString(Buffer.alloc(0)), // critical options (none)
|
||||
sshString(extensions),
|
||||
sshString(Buffer.alloc(0)), // reserved
|
||||
sshString(signatureKey),
|
||||
]);
|
||||
|
||||
const caKey = crypto.createPrivateKey({
|
||||
key: opts.caPrivateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
});
|
||||
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
|
||||
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
|
||||
|
||||
const cert = Buffer.concat([body, sshString(signature)]);
|
||||
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyAlgo,
|
||||
parsePublicKey,
|
||||
matchesAlgoFilter,
|
||||
MAX_PUBLIC_KEY_LENGTH,
|
||||
} from "./termix-id-keys.js";
|
||||
|
||||
// Build a valid OpenSSH public-key line for a given type by encoding a wire
|
||||
// blob whose first string field equals the type (what parsePublicKey checks).
|
||||
function makeKey(type: string, comment = ""): string {
|
||||
const typeBuf = Buffer.from(type, "utf8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(typeBuf.length, 0);
|
||||
const body = Buffer.alloc(40); // arbitrary trailing key material
|
||||
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
|
||||
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
|
||||
}
|
||||
|
||||
describe("classifyAlgo", () => {
|
||||
it("maps known types to normalized groups", () => {
|
||||
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
|
||||
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
|
||||
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
|
||||
expect(classifyAlgo("ssh-dss")).toBe("DSA");
|
||||
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
|
||||
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
|
||||
});
|
||||
|
||||
it("falls back by substring for unknown variants", () => {
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
|
||||
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
|
||||
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePublicKey", () => {
|
||||
it("parses a valid ed25519 key and extracts the comment", () => {
|
||||
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.type).toBe("ssh-ed25519");
|
||||
expect(parsed?.algorithm).toBe("ED25519");
|
||||
expect(parsed?.comment).toBe("alice@laptop");
|
||||
// Comment is stripped from the normalized (dedupe) form.
|
||||
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses SK (FIDO) key types", () => {
|
||||
expect(
|
||||
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
|
||||
).toBe("ED25519-SK");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null],
|
||||
[undefined],
|
||||
[""],
|
||||
[" "],
|
||||
["ssh-ed25519"], // missing blob
|
||||
["ssh-ed25519 not_base64!!"], // bad base64 charset
|
||||
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
|
||||
])("rejects malformed input %p", (input) => {
|
||||
expect(parsePublicKey(input as string)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an over-length line (amplification guard)", () => {
|
||||
const valid = makeKey("ssh-ed25519");
|
||||
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(parsePublicKey(padded)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a blob whose embedded type does not match the prefix", () => {
|
||||
// Declared ssh-rsa but the wire blob says ssh-ed25519.
|
||||
const blob = makeKey("ssh-ed25519").split(" ")[1];
|
||||
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAlgoFilter", () => {
|
||||
it("returns all keys when no filter", () => {
|
||||
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches exactly and is case-insensitive", () => {
|
||||
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
|
||||
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
|
||||
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
|
||||
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Pure, dependency-free helpers for SSH public-key parsing/classification used
|
||||
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
|
||||
|
||||
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
|
||||
// line is ~720 chars); this caps the value the unauthenticated resolver later
|
||||
// streams, preventing a multi-MB blob being stored and amplified.
|
||||
export const MAX_PUBLIC_KEY_LENGTH = 8192;
|
||||
|
||||
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
|
||||
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
|
||||
export const ALGO_GROUPS: Record<string, string> = {
|
||||
"ssh-rsa": "RSA",
|
||||
"rsa-sha2-256": "RSA",
|
||||
"rsa-sha2-512": "RSA",
|
||||
"ssh-dss": "DSA",
|
||||
"ssh-ed25519": "ED25519",
|
||||
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
|
||||
"ecdsa-sha2-nistp256": "ECDSA",
|
||||
"ecdsa-sha2-nistp384": "ECDSA",
|
||||
"ecdsa-sha2-nistp521": "ECDSA",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
|
||||
};
|
||||
|
||||
export function classifyAlgo(type: string): string {
|
||||
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
|
||||
if (type.startsWith("ecdsa-")) return "ECDSA";
|
||||
if (type.includes("ed25519")) return "ED25519";
|
||||
if (type.includes("rsa")) return "RSA";
|
||||
if (type.includes("dss") || type.includes("dsa")) return "DSA";
|
||||
return type.toUpperCase();
|
||||
}
|
||||
|
||||
export interface ParsedPublicKey {
|
||||
type: string;
|
||||
algorithm: string;
|
||||
comment: string;
|
||||
normalized: string; // "<type> <base64blob>"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a single OpenSSH public key line. Returns null when the
|
||||
* input is not a well-formed public key.
|
||||
*/
|
||||
export function parsePublicKey(
|
||||
raw: string | null | undefined,
|
||||
): ParsedPublicKey | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
|
||||
const line = raw.trim().replace(/\s+/g, " ");
|
||||
if (!line) return null;
|
||||
|
||||
const parts = line.split(" ");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const [type, blob, ...rest] = parts;
|
||||
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
|
||||
|
||||
const decoded = Buffer.from(blob, "base64");
|
||||
// The blob is an SSH wire-format string whose first field repeats the type.
|
||||
if (decoded.length < 8) return null;
|
||||
try {
|
||||
const len = decoded.readUInt32BE(0);
|
||||
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
|
||||
const embeddedType = decoded.toString("utf8", 4, 4 + len);
|
||||
if (embeddedType !== type) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
algorithm: classifyAlgo(type),
|
||||
comment: rest.join(" "),
|
||||
normalized: `${type} ${blob}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
|
||||
* filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
|
||||
*/
|
||||
export function matchesAlgoFilter(
|
||||
algorithm: string,
|
||||
filter: string | null,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
return algorithm.toUpperCase() === filter.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = "user-1";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-crypto.js", () => ({
|
||||
UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock("./termix-id-keys.js", () => ({
|
||||
termixIdKeysRouter: { use: vi.fn() },
|
||||
matchesAlgoFilter: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/simple-db-ops.js", () => ({
|
||||
SimpleDBOps: vi.fn().mockImplementation(() => ({
|
||||
findOne: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"where",
|
||||
"set",
|
||||
"values",
|
||||
"returning",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"and",
|
||||
"eq",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
eb?: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb, eb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
|
||||
|
||||
describe("GET /termix-id/linked-credentials", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty list when user has no identity", async () => {
|
||||
// First select (getIdentityForUser) returns nothing; second should not be called
|
||||
mockSelect.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty list when identity has no keys", async () => {
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
|
||||
.mockReturnValueOnce(makeChain([])); // keys lookup
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns deduplicated credentialIds for enabled keys", async () => {
|
||||
const keys = [
|
||||
{ credentialId: 10 },
|
||||
{ credentialId: 20 },
|
||||
{ credentialId: 10 }, // duplicate
|
||||
];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes keys with null credentialId", async () => {
|
||||
const keys = [{ credentialId: null }, { credentialId: 5 }];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import {
|
||||
sshCredentialUsage,
|
||||
recentActivity,
|
||||
snippets,
|
||||
webauthnCredentials,
|
||||
} from "../db/schema.js";
|
||||
|
||||
interface UserPasswordResetRoutesDeps {
|
||||
@@ -440,6 +441,9 @@ export function registerUserPasswordResetRoutes(
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
await db
|
||||
.delete(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
@@ -23,6 +23,7 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
||||
showHostTags: row?.showHostTags ?? null,
|
||||
hostTrayOnClick: row?.hostTrayOnClick ?? null,
|
||||
pinAppRail: row?.pinAppRail ?? null,
|
||||
expandAppRailOnHover: row?.expandAppRailOnHover ?? null,
|
||||
foldersCollapsed: row?.foldersCollapsed ?? null,
|
||||
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
|
||||
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
||||
@@ -79,6 +80,9 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
||||
* pinAppRail:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* expandAppRailOnHover:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* foldersCollapsed:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
@@ -156,6 +160,8 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* pinAppRail:
|
||||
* type: boolean
|
||||
* expandAppRailOnHover:
|
||||
* type: boolean
|
||||
* foldersCollapsed:
|
||||
* type: boolean
|
||||
* confirmSnippetExecution:
|
||||
@@ -188,6 +194,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
@@ -207,6 +214,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
@@ -249,6 +257,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
expandAppRailOnHover,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
@@ -274,6 +283,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
if (showHostTags !== undefined) updates.showHostTags = showHostTags;
|
||||
if (hostTrayOnClick !== undefined) updates.hostTrayOnClick = hostTrayOnClick;
|
||||
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
|
||||
if (expandAppRailOnHover !== undefined)
|
||||
updates.expandAppRailOnHover = expandAppRailOnHover;
|
||||
if (foldersCollapsed !== undefined)
|
||||
updates.foldersCollapsed = foldersCollapsed;
|
||||
if (confirmSnippetExecution !== undefined)
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
function getDefaultGuacUrl(): string {
|
||||
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
}
|
||||
import {
|
||||
formatGuacdOptions,
|
||||
resolveGuacdOptions,
|
||||
} from "../../utils/guacd-config.js";
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
@@ -70,7 +70,7 @@ export function registerUserSettingsRoutes(
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
|
||||
url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get guacamole settings", err);
|
||||
@@ -161,7 +161,7 @@ export function registerUserSettingsRoutes(
|
||||
|
||||
res.json({
|
||||
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
|
||||
url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update guacamole settings", err);
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
RegistrationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
Base64URLString,
|
||||
WebAuthnCredential,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import { db, saveMemoryDatabaseToFile } from "../db/index.js";
|
||||
import { users, webauthnCredentials } from "../db/schema.js";
|
||||
|
||||
type UserVerification = "discouraged" | "preferred" | "required";
|
||||
type NativeAppRequestChecker = (req: Request) => boolean;
|
||||
|
||||
interface WebAuthnRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
interface ChallengeRecord {
|
||||
challenge: string;
|
||||
userId?: string;
|
||||
rpID: string;
|
||||
origin: string;
|
||||
userVerification: UserVerification;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const challengeTtlMs = 5 * 60 * 1000;
|
||||
const registrationChallenges = new Map<string, ChallengeRecord>();
|
||||
const authenticationChallenges = new Map<string, ChallengeRecord>();
|
||||
|
||||
function normalizeUserVerification(value: unknown): UserVerification {
|
||||
return value === "discouraged" || value === "required" ? value : "preferred";
|
||||
}
|
||||
|
||||
function getRequestOrigin(req: Request): string {
|
||||
const origin = req.get("origin");
|
||||
if (origin) return origin;
|
||||
|
||||
const proto = req.get("x-forwarded-proto") || req.protocol || "http";
|
||||
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
|
||||
return `${proto.split(",")[0]}://${host.split(",")[0]}`;
|
||||
}
|
||||
|
||||
function getRpID(origin: string): string {
|
||||
return new URL(origin).hostname;
|
||||
}
|
||||
|
||||
function pruneChallenges(map: Map<string, ChallengeRecord>): void {
|
||||
const now = Date.now();
|
||||
for (const [id, record] of map) {
|
||||
if (now - record.createdAt > challengeTtlMs) {
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function putChallenge(
|
||||
map: Map<string, ChallengeRecord>,
|
||||
record: Omit<ChallengeRecord, "createdAt">,
|
||||
): string {
|
||||
pruneChallenges(map);
|
||||
const challengeId = nanoid();
|
||||
map.set(challengeId, { ...record, createdAt: Date.now() });
|
||||
return challengeId;
|
||||
}
|
||||
|
||||
function takeChallenge(
|
||||
map: Map<string, ChallengeRecord>,
|
||||
challengeId: unknown,
|
||||
): ChallengeRecord | null {
|
||||
if (typeof challengeId !== "string") return null;
|
||||
pruneChallenges(map);
|
||||
const record = map.get(challengeId);
|
||||
if (!record) return null;
|
||||
map.delete(challengeId);
|
||||
return record;
|
||||
}
|
||||
|
||||
function toBase64Url(value: Uint8Array): string {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(value, "base64url"));
|
||||
}
|
||||
|
||||
function parseTransports(value: string | null): AuthenticatorTransportFuture[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getCredentialForVerification(
|
||||
credential: typeof webauthnCredentials.$inferSelect,
|
||||
): WebAuthnCredential {
|
||||
return {
|
||||
id: credential.credentialId as Base64URLString,
|
||||
publicKey: fromBase64Url(
|
||||
credential.publicKey,
|
||||
) as WebAuthnCredential["publicKey"],
|
||||
counter: credential.counter,
|
||||
transports: parseTransports(credential.transports),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerUserWebAuthnRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps,
|
||||
): void {
|
||||
router.get("/webauthn/credentials", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
|
||||
res.json({
|
||||
credentials: credentials.map((credential) => ({
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: credential.backedUp,
|
||||
transports: parseTransports(credential.transports),
|
||||
userVerification: credential.userVerification,
|
||||
createdAt: credential.createdAt,
|
||||
lastUsedAt: credential.lastUsedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/webauthn/register/options",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!authManager.getUserDataKey(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "User data is locked. Log in again before adding a passkey.",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user.length) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
|
||||
const origin = getRequestOrigin(req);
|
||||
const rpID = getRpID(origin);
|
||||
const userVerification = normalizeUserVerification(
|
||||
req.body?.userVerification,
|
||||
);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: "Termix",
|
||||
rpID,
|
||||
userID: Buffer.from(userId, "utf8"),
|
||||
userName: user[0].username,
|
||||
userDisplayName: user[0].username,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "required",
|
||||
userVerification,
|
||||
},
|
||||
});
|
||||
|
||||
const challengeId = putChallenge(registrationChallenges, {
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpID,
|
||||
origin,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
res.json({ options, challengeId });
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/webauthn/register/verify",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const challenge = takeChallenge(
|
||||
registrationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge || challenge.userId !== userId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Registration challenge expired" });
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: req.body?.response as RegistrationResponseJSON,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
const transports =
|
||||
(req.body?.response as RegistrationResponseJSON | undefined)?.response
|
||||
?.transports ?? [];
|
||||
|
||||
await authManager.setupWebAuthnUserEncryption(userId);
|
||||
|
||||
const name =
|
||||
typeof req.body?.name === "string" && req.body.name.trim()
|
||||
? req.body.name.trim().slice(0, 80)
|
||||
: "Passkey";
|
||||
|
||||
await db.insert(webauthnCredentials).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
name,
|
||||
credentialId: credential.id,
|
||||
publicKey: toBase64Url(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: JSON.stringify(transports),
|
||||
userVerification: challenge.userVerification,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await saveMemoryDatabaseToFile();
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn registration failed", {
|
||||
operation: "webauthn_register_verify",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
res.status(400).json({ error: "Passkey registration failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/webauthn/authenticate/options", async (req, res) => {
|
||||
const origin = getRequestOrigin(req);
|
||||
const rpID = getRpID(origin);
|
||||
const userVerification = normalizeUserVerification(
|
||||
req.body?.userVerification,
|
||||
);
|
||||
const username =
|
||||
typeof req.body?.username === "string" ? req.body.username.trim() : "";
|
||||
|
||||
let userId: string | undefined;
|
||||
let allowCredentials:
|
||||
| { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[]
|
||||
| undefined;
|
||||
|
||||
if (username) {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
if (!user.length) {
|
||||
return res.status(404).json({ error: "No passkeys found" });
|
||||
}
|
||||
|
||||
userId = user[0].id;
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId));
|
||||
|
||||
if (!credentials.length) {
|
||||
return res.status(404).json({ error: "No passkeys found" });
|
||||
}
|
||||
|
||||
allowCredentials = credentials.map((credential) => ({
|
||||
id: credential.credentialId as Base64URLString,
|
||||
transports: parseTransports(credential.transports),
|
||||
}));
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
const challengeId = putChallenge(authenticationChallenges, {
|
||||
challenge: options.challenge,
|
||||
userId,
|
||||
rpID,
|
||||
origin,
|
||||
userVerification,
|
||||
});
|
||||
|
||||
res.json({ options, challengeId });
|
||||
});
|
||||
|
||||
router.post("/webauthn/authenticate/verify", async (req, res) => {
|
||||
const challenge = takeChallenge(
|
||||
authenticationChallenges,
|
||||
req.body?.challengeId,
|
||||
);
|
||||
if (!challenge) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Authentication challenge expired" });
|
||||
}
|
||||
|
||||
const response = req.body?.response as
|
||||
| AuthenticationResponseJSON
|
||||
| undefined;
|
||||
if (!response?.id) {
|
||||
return res.status(400).json({ error: "Invalid passkey response" });
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.credentialId, response.id));
|
||||
|
||||
if (!credentials.length) {
|
||||
return res.status(401).json({ error: "Passkey not recognized" });
|
||||
}
|
||||
|
||||
const credential = credentials[0];
|
||||
if (challenge.userId && challenge.userId !== credential.userId) {
|
||||
return res.status(401).json({ error: "Passkey not recognized" });
|
||||
}
|
||||
|
||||
try {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: challenge.origin,
|
||||
expectedRPID: challenge.rpID,
|
||||
credential: getCredentialForVerification(credential),
|
||||
requireUserVerification: challenge.userVerification === "required",
|
||||
advancedFIDOConfig: {
|
||||
userVerification: challenge.userVerification,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return res.status(401).json({ error: "Passkey authentication failed" });
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, credential.userId));
|
||||
if (!user.length) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const dataUnlocked = await authManager.authenticateWebAuthnUser(
|
||||
userRecord.id,
|
||||
deviceInfo.type,
|
||||
);
|
||||
|
||||
if (!dataUnlocked) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Passkey cannot unlock this account. Log in with password and register the passkey again.",
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(webauthnCredentials)
|
||||
.set({
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
backedUp: verification.authenticationInfo.credentialBackedUp,
|
||||
deviceType: verification.authenticationInfo.credentialDeviceType,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(webauthnCredentials.id, credential.id));
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
const isTrusted = await authManager.isTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
);
|
||||
|
||||
if (!isTrusted) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
expiresIn: "10m",
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
requires_totp: true,
|
||||
temp_token: tempToken,
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'session_timeout_hours'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutHours = timeoutRow
|
||||
? parseInt(timeoutRow.value, 10) || 24
|
||||
: 24;
|
||||
const maxAge = req.body?.rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge));
|
||||
res.json({
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
userId: userRecord.id,
|
||||
is_oidc: !!userRecord.isOidc,
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
data_unlocked: true,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.warn("WebAuthn authentication failed", {
|
||||
operation: "webauthn_auth_verify",
|
||||
credentialId: credential.id,
|
||||
userId: credential.userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
res.status(401).json({ error: "Passkey authentication failed" });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete(
|
||||
"/webauthn/credentials/:credentialId",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const credentialId = String(req.params.credentialId);
|
||||
|
||||
await db
|
||||
.delete(webauthnCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(webauthnCredentials.id, credentialId),
|
||||
eq(webauthnCredentials.userId, userId),
|
||||
),
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
res.json({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
generateDeviceFingerprint,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
||||
import {
|
||||
getRequestBasePath,
|
||||
getRequestBaseUrlWithForceHTTPS,
|
||||
} from "../../utils/request-origin.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
@@ -33,6 +36,7 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js";
|
||||
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
|
||||
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
@@ -41,6 +45,25 @@ const authManager = AuthManager.getInstance();
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
async function syncSharedCredentialsForUserRoles(
|
||||
userId: string,
|
||||
operation: string,
|
||||
) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.createSharedCredentialsForUserRoles(userId);
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userId);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to sync role shared credentials", {
|
||||
operation,
|
||||
userId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
@@ -610,8 +633,8 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
appCallbackUrl,
|
||||
providerId: providerIdStr,
|
||||
} = req.query;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
||||
const publicBaseUrl = getRequestBaseUrlWithForceHTTPS(req);
|
||||
const backendCallbackUri = `${publicBaseUrl}/users/oidc/callback`;
|
||||
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
@@ -643,9 +666,9 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
frontendOrigin = callbackUrl.toString();
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}${getRequestBasePath(req)}`;
|
||||
} else {
|
||||
frontendOrigin = origin;
|
||||
frontendOrigin = publicBaseUrl;
|
||||
}
|
||||
|
||||
db.$client
|
||||
@@ -946,7 +969,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(ghId, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
} catch {
|
||||
await db.delete(users).where(eq(users.id, ghId));
|
||||
return res.status(500).json({
|
||||
error: "Failed to setup user security - user creation cancelled",
|
||||
@@ -965,6 +988,10 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
ghUserRecord.id,
|
||||
"github_oidc_role_shared_credentials",
|
||||
);
|
||||
const ghToken = await authManager.generateJWTToken(ghUserRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
@@ -1432,14 +1459,10 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
||||
} catch {
|
||||
// expected - re-encryption may fail if no pending credentials
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
userRecord.id,
|
||||
"oidc_role_shared_credentials",
|
||||
);
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
@@ -1642,18 +1665,10 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to re-encrypt pending shared credentials", {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId: userRecord.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
await syncSharedCredentialsForUserRoles(
|
||||
userRecord.id,
|
||||
"login_role_shared_credentials",
|
||||
);
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
@@ -2520,6 +2535,12 @@ registerUserTotpRoutes(router, {
|
||||
isNativeAppRequest,
|
||||
});
|
||||
|
||||
registerUserWebAuthnRoutes(router, {
|
||||
authenticateJWT,
|
||||
authManager,
|
||||
isNativeAppRequest,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/delete-user:
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users, vaultProfiles } from "../db/schema.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { completeVaultAuth } from "../../ssh/vault-oidc-auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
async function userIsAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return !!rows[0]?.isAdmin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatProfile(
|
||||
row: Record<string, unknown>,
|
||||
currentUserId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
folder: row.folder,
|
||||
tags:
|
||||
typeof row.tags === "string"
|
||||
? row.tags
|
||||
? (row.tags as string).split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
vaultAddr: row.vaultAddr,
|
||||
vaultNamespace: row.vaultNamespace,
|
||||
oidcMount: row.oidcMount,
|
||||
oidcRole: row.oidcRole,
|
||||
sshMount: row.sshMount,
|
||||
sshRole: row.sshRole,
|
||||
validPrincipals: row.validPrincipals,
|
||||
keyType: row.keyType,
|
||||
shared: !!row.shared,
|
||||
owned: row.userId === currentUserId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/oidc/callback:
|
||||
* get:
|
||||
* summary: Vault OIDC callback
|
||||
* description: Unauthenticated endpoint the IdP redirects to after login. Correlates the authorization code to a pending session via the Vault-issued state parameter.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: state
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: error
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: HTML page confirming sign-in success or failure.
|
||||
* 400:
|
||||
* description: Missing parameters or authentication failure.
|
||||
*/
|
||||
router.get("/oidc/callback", async (req: Request, res: Response) => {
|
||||
const state = String(req.query.state || "");
|
||||
const code = String(req.query.code || "");
|
||||
const oidcError = req.query.error ? String(req.query.error) : "";
|
||||
|
||||
const html = (title: string, message: string) =>
|
||||
`<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;background:#0b0b0c;color:#e5e5e5;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
||||
.card{max-width:420px;text-align:center;padding:24px;border:1px solid #2a2a2e;border-radius:8px}</style></head>
|
||||
<body><div class="card"><h2>${title}</h2><p>${message}</p>
|
||||
<script>setTimeout(function(){window.close()},1500)</script></div></body></html>`;
|
||||
|
||||
if (oidcError) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(html("Vault sign-in failed", `Vault returned: ${oidcError}`));
|
||||
}
|
||||
if (!state || !code) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(html("Vault sign-in failed", "Missing state or code."));
|
||||
}
|
||||
|
||||
const result = await completeVaultAuth(state, code);
|
||||
if (result.ok) {
|
||||
return res.send(
|
||||
html(
|
||||
"Vault sign-in complete",
|
||||
"You can close this window and return to Termix.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
html("Vault sign-in failed", result.error || "Authentication failed."),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles:
|
||||
* get:
|
||||
* summary: List Vault profiles
|
||||
* description: Returns all Vault signer profiles owned by the authenticated user or marked as shared.
|
||||
* tags:
|
||||
* - Vault
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of Vault profile objects.
|
||||
* 500:
|
||||
* description: Failed to list vault profiles.
|
||||
*/
|
||||
router.get(
|
||||
"/profiles",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(
|
||||
or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)),
|
||||
)
|
||||
.orderBy(desc(vaultProfiles.updatedAt));
|
||||
res.json(
|
||||
rows.map((r) => formatProfile(r as Record<string, unknown>, userId)),
|
||||
);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list vault profiles", err);
|
||||
res.status(500).json({ error: "Failed to list vault profiles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles:
|
||||
* post:
|
||||
* summary: Create a Vault profile
|
||||
* description: Creates a new Vault signer profile owned by the authenticated user. The shared flag requires admin privileges.
|
||||
* tags:
|
||||
* - Vault
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - vaultAddr
|
||||
* - sshRole
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* vaultAddr:
|
||||
* type: string
|
||||
* vaultNamespace:
|
||||
* type: string
|
||||
* oidcMount:
|
||||
* type: string
|
||||
* oidcRole:
|
||||
* type: string
|
||||
* sshMount:
|
||||
* type: string
|
||||
* sshRole:
|
||||
* type: string
|
||||
* validPrincipals:
|
||||
* type: string
|
||||
* keyType:
|
||||
* type: string
|
||||
* shared:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Created Vault profile object.
|
||||
* 400:
|
||||
* description: Missing required fields.
|
||||
* 403:
|
||||
* description: Non-admin attempted to create a shared profile.
|
||||
* 500:
|
||||
* description: Failed to create vault profile.
|
||||
*/
|
||||
router.post(
|
||||
"/profiles",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
folder,
|
||||
tags,
|
||||
vaultAddr,
|
||||
vaultNamespace,
|
||||
oidcMount,
|
||||
oidcRole,
|
||||
sshMount,
|
||||
sshRole,
|
||||
validPrincipals,
|
||||
keyType,
|
||||
shared,
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(name) ||
|
||||
!isNonEmptyString(vaultAddr) ||
|
||||
!isNonEmptyString(sshRole)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "name, vaultAddr and sshRole are required" });
|
||||
}
|
||||
|
||||
const wantShared = !!shared;
|
||||
if (wantShared && !(await userIsAdmin(userId))) {
|
||||
return res.status(403).json({
|
||||
error: "Only administrators can create shared Vault profiles",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const inserted = await db
|
||||
.insert(vaultProfiles)
|
||||
.values({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
vaultAddr: vaultAddr.trim(),
|
||||
vaultNamespace: vaultNamespace?.trim() || null,
|
||||
oidcMount: oidcMount?.trim() || null,
|
||||
oidcRole: oidcRole?.trim() || null,
|
||||
sshMount: sshMount?.trim() || null,
|
||||
sshRole: sshRole.trim(),
|
||||
validPrincipals: validPrincipals?.trim() || null,
|
||||
keyType: keyType?.trim() || null,
|
||||
shared: wantShared,
|
||||
})
|
||||
.returning();
|
||||
res
|
||||
.status(201)
|
||||
.json(formatProfile(inserted[0] as Record<string, unknown>, userId));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create vault profile", err);
|
||||
res.status(500).json({ error: "Failed to create vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles/{id}:
|
||||
* put:
|
||||
* summary: Update a Vault profile
|
||||
* description: Updates a Vault signer profile. Only the owner may edit; toggling shared to true requires admin privileges.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Updated Vault profile object.
|
||||
* 400:
|
||||
* description: Invalid profile id.
|
||||
* 403:
|
||||
* description: Non-owner attempted to edit, or non-admin attempted to share.
|
||||
* 404:
|
||||
* description: Profile not found.
|
||||
* 500:
|
||||
* description: Failed to update vault profile.
|
||||
*/
|
||||
router.put(
|
||||
"/profiles/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return res.status(400).json({ error: "Invalid profile id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.limit(1);
|
||||
if (!existing.length) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
if (existing[0].userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can edit this profile" });
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
const fields: Record<string, unknown> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (body.name !== undefined) fields.name = body.name?.trim();
|
||||
if (body.description !== undefined)
|
||||
fields.description = body.description?.trim() || null;
|
||||
if (body.folder !== undefined)
|
||||
fields.folder = body.folder?.trim() || null;
|
||||
if (body.tags !== undefined)
|
||||
fields.tags = Array.isArray(body.tags)
|
||||
? body.tags.join(",")
|
||||
: body.tags || "";
|
||||
if (body.vaultAddr !== undefined && isNonEmptyString(body.vaultAddr))
|
||||
fields.vaultAddr = body.vaultAddr.trim();
|
||||
if (body.vaultNamespace !== undefined)
|
||||
fields.vaultNamespace = body.vaultNamespace?.trim() || null;
|
||||
if (body.oidcMount !== undefined)
|
||||
fields.oidcMount = body.oidcMount?.trim() || null;
|
||||
if (body.oidcRole !== undefined)
|
||||
fields.oidcRole = body.oidcRole?.trim() || null;
|
||||
if (body.sshMount !== undefined)
|
||||
fields.sshMount = body.sshMount?.trim() || null;
|
||||
if (body.sshRole !== undefined && isNonEmptyString(body.sshRole))
|
||||
fields.sshRole = body.sshRole.trim();
|
||||
if (body.validPrincipals !== undefined)
|
||||
fields.validPrincipals = body.validPrincipals?.trim() || null;
|
||||
if (body.keyType !== undefined)
|
||||
fields.keyType = body.keyType?.trim() || null;
|
||||
if (body.shared !== undefined) {
|
||||
if (!!body.shared && !(await userIsAdmin(userId))) {
|
||||
return res.status(403).json({
|
||||
error: "Only administrators can share Vault profiles",
|
||||
});
|
||||
}
|
||||
fields.shared = !!body.shared;
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(vaultProfiles)
|
||||
.set(fields)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.returning();
|
||||
res.json(formatProfile(updated[0] as Record<string, unknown>, userId));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update vault profile", err);
|
||||
res.status(500).json({ error: "Failed to update vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /vault/profiles/{id}:
|
||||
* delete:
|
||||
* summary: Delete a Vault profile
|
||||
* description: Permanently deletes a Vault signer profile. Only the owner may delete it.
|
||||
* tags:
|
||||
* - Vault
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deletion confirmed.
|
||||
* 400:
|
||||
* description: Invalid profile id.
|
||||
* 403:
|
||||
* description: Non-owner attempted to delete.
|
||||
* 404:
|
||||
* description: Profile not found.
|
||||
* 500:
|
||||
* description: Failed to delete vault profile.
|
||||
*/
|
||||
router.delete(
|
||||
"/profiles/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
return res.status(400).json({ error: "Invalid profile id" });
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.limit(1);
|
||||
if (!existing.length) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
if (existing[0].userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the owner can delete this profile" });
|
||||
}
|
||||
await db.delete(vaultProfiles).where(eq(vaultProfiles.id, id));
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete vault profile", err);
|
||||
res.status(500).json({ error: "Failed to delete vault profile" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -2,34 +2,22 @@ import GuacamoleLite from "guacamole-lite";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||
const parts = url.split(":");
|
||||
return {
|
||||
host: parts[0] || "localhost",
|
||||
port: parseInt(parts[1] || "4822", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let host = process.env.GUACD_HOST || "localhost";
|
||||
let port = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parsed = parseGuacUrl(urlRow.value);
|
||||
host = parsed.host;
|
||||
port = parsed.port;
|
||||
}
|
||||
dbUrl = urlRow?.value;
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return { host, port };
|
||||
return resolveGuacdOptions(dbUrl);
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
@@ -589,21 +590,17 @@ router.post(
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
let guacdHost = process.env.GUACD_HOST || "localhost";
|
||||
let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
let dbUrl: string | undefined;
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parts = urlRow.value.split(":");
|
||||
guacdHost = parts[0] || guacdHost;
|
||||
guacdPort = parseInt(parts[1] || String(guacdPort), 10);
|
||||
}
|
||||
dbUrl = urlRow?.value;
|
||||
} catch {
|
||||
// Fall back to env vars
|
||||
}
|
||||
const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl);
|
||||
|
||||
const net = await import("net");
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../utils/logger.js", () => ({
|
||||
guacLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { GuacamoleTokenService } = await import("./token-service.js");
|
||||
|
||||
describe("GuacamoleTokenService", () => {
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
it("disables RDP pre-authentication when no credentials are configured", () => {
|
||||
const token = tokenService.createRdpToken("windows.example.test", "", "");
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
"disable-auth": true,
|
||||
});
|
||||
expect(decrypted?.connection.settings.username).toBeUndefined();
|
||||
expect(decrypted?.connection.settings.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps normal RDP credential authentication unchanged", () => {
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.settings).toMatchObject({
|
||||
hostname: "windows.example.test",
|
||||
username: "Administrator",
|
||||
password: "secret",
|
||||
port: 3389,
|
||||
});
|
||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export interface GuacamoleConnectionSettings {
|
||||
dpi?: number;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"disable-auth"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
@@ -139,6 +140,7 @@ export class GuacamoleTokenService {
|
||||
...(password ? { password } : {}),
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
...(!username && !password ? { "disable-auth": true } : {}),
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { homepageItemsRouter } from "./database/routes/homepage-items-routes.js";
|
||||
import { homepageLayoutRouter } from "./database/routes/homepage-layout-routes.js";
|
||||
import { homepageFaviconRouter } from "./database/routes/homepage-favicon-routes.js";
|
||||
import { homepageRssRouter } from "./database/routes/homepage-rss-routes.js";
|
||||
import { homepagePingRouter } from "./database/routes/homepage-ping-routes.js";
|
||||
import { homepageProxyRouter } from "./database/routes/homepage-proxy-routes.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const PORT = 30012;
|
||||
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
app.use("/homepage/items", homepageItemsRouter);
|
||||
app.use("/homepage/layout", homepageLayoutRouter);
|
||||
app.use("/homepage/favicon", homepageFaviconRouter);
|
||||
app.use("/homepage/rss", homepageRssRouter);
|
||||
app.use("/homepage/ping", homepagePingRouter);
|
||||
app.use("/homepage/proxy", homepageProxyRouter);
|
||||
|
||||
app.listen(PORT, "127.0.0.1", () => {});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,204 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { SerialPort } from "serialport";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
interface SerialConnectData {
|
||||
path: string;
|
||||
baudRate: number;
|
||||
dataBits?: 5 | 6 | 7 | 8;
|
||||
stopBits?: 1 | 2;
|
||||
parity?: "none" | "even" | "odd";
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data?: SerialConnectData | string | unknown;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const wss = new WebSocketServer({ port: 30011 });
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
userId = payload.userId;
|
||||
} catch {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
|
||||
ws.close(1008, "Data access required");
|
||||
return;
|
||||
}
|
||||
|
||||
let port: SerialPort | null = null;
|
||||
|
||||
const send = (msg: object) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
}
|
||||
port = null;
|
||||
};
|
||||
|
||||
ws.on("message", async (raw: RawData) => {
|
||||
let parsed: WebSocketMessage;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString()) as WebSocketMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: err instanceof Error ? err.message : "Failed to list ports",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "connect": {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
port = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
|
||||
port.open((err) => {
|
||||
if (err) {
|
||||
sshLogger.error("Serial port open failed", err, {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
sshLogger.info("Serial port opened", {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
userId,
|
||||
});
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
port.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
|
||||
port.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
|
||||
port.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data:
|
||||
err instanceof Error ? err.message : "Failed to open serial port",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) {
|
||||
send({ type: "error", data: err.message });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -146,7 +146,9 @@ import {
|
||||
await import("./ssh/docker.js");
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./ssh/tmux-monitor.js"); // --- tmux-monitor ---
|
||||
await import("./serial/serial.js");
|
||||
await import("./dashboard.js");
|
||||
await import("./homepage.js");
|
||||
|
||||
// Initialize log level from database settings
|
||||
const { getDb: getDbForSettings } = await import("./database/db/index.js");
|
||||
|
||||
@@ -42,6 +42,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30007",
|
||||
description: "Docker management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30011",
|
||||
description: "Serial connection server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { sshLogger } from "./logger.js";
|
||||
|
||||
const METRICS_SERVICE_URL = "http://localhost:30005";
|
||||
|
||||
export async function triggerLoginAlert(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const token = await SystemCrypto.getInstance().getInternalAuthToken();
|
||||
await fetch(`${METRICS_SERVICE_URL}/internal/login-alert`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-auth": token,
|
||||
},
|
||||
body: JSON.stringify({ hostId, userId, sshUser, fromIp }),
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to trigger login alert", {
|
||||
operation: "login_alert_trigger_error",
|
||||
hostId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,31 @@ class AuthManager {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async setupWebAuthnUserEncryption(userId: string): Promise<void> {
|
||||
await this.userCrypto.setupWebAuthnUserEncryption(userId);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnUser(
|
||||
userId: string,
|
||||
deviceType?: DeviceType,
|
||||
): Promise<boolean> {
|
||||
const sessionDurationMs =
|
||||
deviceType === "desktop" || deviceType === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
const authenticated = await this.userCrypto.authenticateWebAuthnUser(
|
||||
userId,
|
||||
sessionDurationMs,
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
await this.performLazyEncryptionMigration(userId);
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
async convertToOIDCEncryption(userId: string): Promise<void> {
|
||||
await this.userCrypto.convertToOIDCEncryption(userId);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ class FieldCrypto {
|
||||
"publicKey",
|
||||
]),
|
||||
opkssh_tokens: new Set(["sshCert", "privateKey"]),
|
||||
termix_identity_ca: new Set(["privateKey"]),
|
||||
vault_tokens: new Set(["sshCert", "privateKey"]),
|
||||
};
|
||||
|
||||
static encryptField(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export type GuacdOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
|
||||
host: "localhost",
|
||||
port: 4822,
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined, fallback: number) {
|
||||
const port = parseInt(value || "", 10);
|
||||
return Number.isFinite(port) ? port : fallback;
|
||||
}
|
||||
|
||||
export function parseGuacdUrl(
|
||||
value: string,
|
||||
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
|
||||
): GuacdOptions {
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (raw.includes("://")) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return {
|
||||
host: url.hostname || fallback.host,
|
||||
port: parsePort(url.port, fallback.port),
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
||||
if (bracketedIpv6) {
|
||||
return {
|
||||
host: bracketedIpv6[1],
|
||||
port: parsePort(bracketedIpv6[2], fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
const [host, port] = raw.split(":");
|
||||
return {
|
||||
host: host || fallback.host,
|
||||
port: parsePort(port, fallback.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuacdEnvOptions(): GuacdOptions | null {
|
||||
if (process.env.GUACD_URL) {
|
||||
return parseGuacdUrl(process.env.GUACD_URL);
|
||||
}
|
||||
|
||||
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
|
||||
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
|
||||
const envOptions = getGuacdEnvOptions();
|
||||
if (envOptions) {
|
||||
return envOptions;
|
||||
}
|
||||
|
||||
if (dbUrl) {
|
||||
return parseGuacdUrl(dbUrl);
|
||||
}
|
||||
|
||||
return DEFAULT_GUACD_OPTIONS;
|
||||
}
|
||||
|
||||
export function formatGuacdOptions(options: GuacdOptions): string {
|
||||
return `${options.host}:${options.port}`;
|
||||
}
|
||||
|
||||
export function getDefaultGuacdUrl(): string {
|
||||
return formatGuacdOptions(resolveGuacdOptions());
|
||||
}
|
||||
@@ -293,5 +293,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { statsLogger } from "./logger.js";
|
||||
|
||||
export interface AlertPayload {
|
||||
hostName: string;
|
||||
hostId: number;
|
||||
triggerType: string;
|
||||
value?: number;
|
||||
threshold?: number;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
timestamp: string;
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
}
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: "POST" | "PUT";
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
const NTFY_PRIORITY: Record<string, number> = {
|
||||
info: 2,
|
||||
warning: 3,
|
||||
critical: 5,
|
||||
};
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
): Promise<void> {
|
||||
const attempt = async () => {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await attempt();
|
||||
} catch (firstErr) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
try {
|
||||
await attempt();
|
||||
} catch (secondErr) {
|
||||
statsLogger.warn("Notification delivery failed after retry", {
|
||||
operation: "notification_send_failed",
|
||||
url,
|
||||
error:
|
||||
secondErr instanceof Error ? secondErr.message : String(secondErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWebhook(
|
||||
config: WebhookConfig,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
const { url, headers = {}, method = "POST" } = config;
|
||||
await fetchWithRetry(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNtfy(
|
||||
config: NtfyConfig,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
const { url, topic, token } = config;
|
||||
const ntfyUrl = `${url.replace(/\/$/, "")}/${topic}`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Title: `[Termix] ${payload.hostName}: ${payload.ruleName}`,
|
||||
Priority: String(NTFY_PRIORITY[payload.severity] ?? 3),
|
||||
Tags:
|
||||
payload.severity === "critical"
|
||||
? "rotating_light"
|
||||
: payload.severity === "warning"
|
||||
? "warning"
|
||||
: "information_source",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
await fetchWithRetry(ntfyUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: payload.message,
|
||||
});
|
||||
}
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function sendNotification(
|
||||
channel: NotificationChannel,
|
||||
payload: AlertPayload,
|
||||
): Promise<void> {
|
||||
if (!channel.enabled) return;
|
||||
|
||||
let parsedConfig: Record<string, unknown>;
|
||||
try {
|
||||
parsedConfig = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
statsLogger.warn("Failed to parse notification channel config", {
|
||||
operation: "notification_config_parse_error",
|
||||
channelId: channel.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (channel.type === "webhook") {
|
||||
await sendWebhook(parsedConfig as unknown as WebhookConfig, payload);
|
||||
} else if (channel.type === "ntfy") {
|
||||
await sendNtfy(parsedConfig as unknown as NtfyConfig, payload);
|
||||
}
|
||||
} catch (err) {
|
||||
statsLogger.warn("Notification send error", {
|
||||
operation: "notification_send_error",
|
||||
channelId: channel.id,
|
||||
type: channel.type,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRequestBasePath,
|
||||
getRequestBaseUrl,
|
||||
getRequestBaseUrlWithForceHTTPS,
|
||||
normalizeBasePath,
|
||||
} from "./request-origin.js";
|
||||
|
||||
function request(headers: Record<string, string | string[] | undefined>) {
|
||||
return {
|
||||
headers,
|
||||
socket: {},
|
||||
} as Parameters<typeof getRequestBasePath>[0];
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
describe("normalizeBasePath", () => {
|
||||
it("normalizes empty and root paths", () => {
|
||||
expect(normalizeBasePath("")).toBe("");
|
||||
expect(normalizeBasePath("/")).toBe("");
|
||||
expect(normalizeBasePath(" / ")).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes configured base paths", () => {
|
||||
expect(normalizeBasePath("termix")).toBe("/termix");
|
||||
expect(normalizeBasePath("/termix/")).toBe("/termix");
|
||||
expect(normalizeBasePath("/termix, /other")).toBe("/termix");
|
||||
});
|
||||
|
||||
it("accepts forwarded prefix values that include a URL", () => {
|
||||
expect(normalizeBasePath("https://example.com/termix/")).toBe("/termix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestBasePath", () => {
|
||||
const previousBasePath = process.env.BASE_PATH;
|
||||
const previousViteBasePath = process.env.VITE_BASE_PATH;
|
||||
const previousForceHttps = process.env.OIDC_FORCE_HTTPS;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("BASE_PATH", previousBasePath);
|
||||
restoreEnv("VITE_BASE_PATH", previousViteBasePath);
|
||||
restoreEnv("OIDC_FORCE_HTTPS", previousForceHttps);
|
||||
});
|
||||
|
||||
it("uses BASE_PATH before forwarded headers", () => {
|
||||
process.env.BASE_PATH = "/admin/";
|
||||
process.env.VITE_BASE_PATH = "";
|
||||
|
||||
expect(
|
||||
getRequestBasePath(request({ "x-forwarded-prefix": "/termix" })),
|
||||
).toBe("/admin");
|
||||
});
|
||||
|
||||
it("falls back to VITE_BASE_PATH for deployments that already set it", () => {
|
||||
process.env.BASE_PATH = "";
|
||||
process.env.VITE_BASE_PATH = "/termix/";
|
||||
|
||||
expect(getRequestBasePath(request({}))).toBe("/termix");
|
||||
});
|
||||
|
||||
it("uses X-Forwarded-Prefix when no env base path is set", () => {
|
||||
process.env.BASE_PATH = "";
|
||||
process.env.VITE_BASE_PATH = "";
|
||||
|
||||
expect(
|
||||
getRequestBasePath(request({ "x-forwarded-prefix": "/termix/" })),
|
||||
).toBe("/termix");
|
||||
});
|
||||
|
||||
it("builds a public base URL with forwarded origin and prefix", () => {
|
||||
process.env.BASE_PATH = "";
|
||||
process.env.VITE_BASE_PATH = "";
|
||||
|
||||
expect(
|
||||
getRequestBaseUrl(
|
||||
request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "example.com",
|
||||
"x-forwarded-prefix": "/termix",
|
||||
}),
|
||||
),
|
||||
).toBe("https://example.com/termix");
|
||||
});
|
||||
|
||||
it("applies OIDC_FORCE_HTTPS to public base URLs", () => {
|
||||
process.env.BASE_PATH = "";
|
||||
process.env.VITE_BASE_PATH = "";
|
||||
process.env.OIDC_FORCE_HTTPS = "true";
|
||||
|
||||
expect(
|
||||
getRequestBaseUrlWithForceHTTPS(
|
||||
request({
|
||||
"x-forwarded-proto": "http",
|
||||
"x-forwarded-host": "example.com",
|
||||
"x-forwarded-prefix": "/termix",
|
||||
}),
|
||||
),
|
||||
).toBe("https://example.com/termix");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,31 @@
|
||||
import type { Request } from "express";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string {
|
||||
if (!value) return "";
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
return raw.split(",")[0].trim();
|
||||
}
|
||||
|
||||
export function normalizeBasePath(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
let basePath = value.split(",")[0].trim();
|
||||
if (!basePath) return "";
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(basePath)) {
|
||||
basePath = new URL(basePath).pathname;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
basePath = basePath.split("?")[0].split("#")[0].trim();
|
||||
if (!basePath || basePath === "/") return "";
|
||||
if (!basePath.startsWith("/")) basePath = `/${basePath}`;
|
||||
return basePath.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
let protocol: string;
|
||||
const protoHeader = req.headers["x-forwarded-proto"];
|
||||
@@ -63,3 +88,26 @@ export function getRequestOriginWithForceHTTPS(
|
||||
}
|
||||
return getRequestOrigin(req);
|
||||
}
|
||||
|
||||
export function getRequestBasePath(req: Request | IncomingMessage): string {
|
||||
const envBasePath = normalizeBasePath(
|
||||
process.env.BASE_PATH || process.env.VITE_BASE_PATH,
|
||||
);
|
||||
if (envBasePath) return envBasePath;
|
||||
|
||||
return (
|
||||
normalizeBasePath(firstHeaderValue(req.headers["x-forwarded-prefix"])) ||
|
||||
normalizeBasePath(firstHeaderValue(req.headers["x-script-name"])) ||
|
||||
normalizeBasePath(firstHeaderValue(req.headers["x-original-prefix"]))
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestBaseUrl(req: Request | IncomingMessage): string {
|
||||
return `${getRequestOrigin(req)}${getRequestBasePath(req)}`;
|
||||
}
|
||||
|
||||
export function getRequestBaseUrlWithForceHTTPS(
|
||||
req: Request | IncomingMessage,
|
||||
): string {
|
||||
return `${getRequestOriginWithForceHTTPS(req)}${getRequestBasePath(req)}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,19 @@ class SharedCredentialManager {
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existing = await db
|
||||
.select({ id: sharedCredentials.id })
|
||||
.from(sharedCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedCredentials.hostAccessId, hostAccessId),
|
||||
eq(sharedCredentials.targetUserId, targetUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
|
||||
if (ownerDEK) {
|
||||
@@ -158,6 +171,83 @@ class SharedCredentialManager {
|
||||
}
|
||||
}
|
||||
|
||||
async createSharedCredentialsForRoleMember(
|
||||
roleId: number,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const hostsSharedWithRole = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
|
||||
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
||||
const activeCredentialId =
|
||||
ssh_data.credentialId ??
|
||||
ssh_data.rdpCredentialId ??
|
||||
ssh_data.vncCredentialId ??
|
||||
ssh_data.telnetCredentialId;
|
||||
|
||||
if (!activeCredentialId) continue;
|
||||
|
||||
try {
|
||||
await this.createSharedCredentialForUser(
|
||||
host_access.id,
|
||||
activeCredentialId,
|
||||
targetUserId,
|
||||
ssh_data.userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role_member",
|
||||
roleId,
|
||||
targetUserId,
|
||||
hostId: ssh_data.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role_member",
|
||||
roleId,
|
||||
targetUserId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createSharedCredentialsForUserRoles(userId: string): Promise<void> {
|
||||
try {
|
||||
const roleRows = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
for (const { roleId } of roleRows) {
|
||||
await this.createSharedCredentialsForRoleMember(roleId, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for user roles",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_user_roles",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSharedCredentialForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ type TableName =
|
||||
| "users"
|
||||
| "ssh_data"
|
||||
| "ssh_credentials"
|
||||
| "termix_identity_ca"
|
||||
| "recent_activity"
|
||||
| "socks5_proxy_presets";
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
parseSSHKey,
|
||||
parsePublicKey,
|
||||
preparePrivateKeyForSSH2,
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
} from "./ssh-key-utils.js";
|
||||
@@ -20,6 +22,11 @@ AAAEDLo85Twyg0v6V1zsJaeRaxq9KPQXkqGY0HiJtVMzCXEFH+Ektft4yKdLjAkx8baBZK
|
||||
const ED25519_PUBLIC =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFH+Ektft4yKdLjAkx8baBZK21SK5Iu+oPBVhPnfHSp7 test@termix.test";
|
||||
|
||||
const PPK_RSA_PRIVATE = readFileSync(
|
||||
"node_modules/ssh2/test/fixtures/keyParser/ppk_rsa",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("parsePublicKey", () => {
|
||||
it("detects ssh-ed25519 public keys", () => {
|
||||
const info = parsePublicKey(ED25519_PUBLIC);
|
||||
@@ -68,6 +75,26 @@ describe("parseSSHKey", () => {
|
||||
expect(info.success).toBe(false);
|
||||
expect(info.keyType).toBe("unknown");
|
||||
});
|
||||
|
||||
it("accepts PuTTY PPK v2 private keys supported by ssh2", () => {
|
||||
const info = parseSSHKey(PPK_RSA_PRIVATE);
|
||||
expect(info.success).toBe(true);
|
||||
expect(info.keyType).toBe("ssh-rsa");
|
||||
expect(info.publicKey).toContain("ssh-rsa");
|
||||
});
|
||||
|
||||
it("prepares PuTTY PPK v2 private keys for ssh2 connections", () => {
|
||||
const prepared = preparePrivateKeyForSSH2(PPK_RSA_PRIVATE);
|
||||
expect(prepared.toString("utf8")).toContain("PuTTY-User-Key-File-2");
|
||||
});
|
||||
|
||||
it("reports unsupported PuTTY PPK versions clearly", () => {
|
||||
const info = parseSSHKey(
|
||||
"PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\n",
|
||||
);
|
||||
expect(info.success).toBe(false);
|
||||
expect(info.error).toMatch(/Unsupported PuTTY PPK v3/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFriendlyKeyTypeName", () => {
|
||||
|
||||
@@ -225,20 +225,53 @@ export interface KeyPairValidationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PUTTY_PRIVATE_KEY_RE = /^PuTTY-User-Key-File-(\d+):\s*(.+)$/m;
|
||||
|
||||
export function normalizePrivateKeyText(privateKeyData: string): string {
|
||||
return privateKeyData.trim().replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
function getUnsupportedPrivateKeyError(privateKeyData: string): string {
|
||||
const puttyMatch = PUTTY_PRIVATE_KEY_RE.exec(privateKeyData.trim());
|
||||
if (puttyMatch) {
|
||||
const [, version, type] = puttyMatch;
|
||||
return `Unsupported PuTTY PPK v${version} private key format (${type}). Convert the key to OpenSSH format with PuTTYgen, or use a PuTTY PPK v2 RSA/DSA key.`;
|
||||
}
|
||||
|
||||
return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.";
|
||||
}
|
||||
|
||||
export function preparePrivateKeyForSSH2(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): Buffer {
|
||||
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||
const keyInfo = parseSSHKey(cleanKey, passphrase);
|
||||
|
||||
if (!keyInfo.success) {
|
||||
throw new Error(keyInfo.error || getUnsupportedPrivateKeyError(cleanKey));
|
||||
}
|
||||
|
||||
return Buffer.from(cleanKey, "utf8");
|
||||
}
|
||||
|
||||
export function parseSSHKey(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
): KeyInfo {
|
||||
try {
|
||||
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||
let keyType = "unknown";
|
||||
let publicKey = "";
|
||||
let useSSH2 = false;
|
||||
|
||||
if (ssh2Utils && typeof ssh2Utils.parseKey === "function") {
|
||||
try {
|
||||
const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase);
|
||||
const parsedKey = ssh2Utils.parseKey(cleanKey, passphrase);
|
||||
|
||||
if (!(parsedKey instanceof Error)) {
|
||||
if (parsedKey instanceof Error) {
|
||||
throw parsedKey;
|
||||
} else {
|
||||
if (parsedKey.type) {
|
||||
keyType = parsedKey.type;
|
||||
}
|
||||
@@ -273,23 +306,28 @@ export function parseSSHKey(
|
||||
}
|
||||
|
||||
if (!useSSH2) {
|
||||
keyType = detectKeyTypeFromContent(privateKeyData);
|
||||
keyType = detectKeyTypeFromContent(cleanKey);
|
||||
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
privateKey: cleanKey,
|
||||
publicKey,
|
||||
keyType,
|
||||
success: keyType !== "unknown",
|
||||
error:
|
||||
keyType === "unknown"
|
||||
? getUnsupportedPrivateKeyError(cleanKey)
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackKeyType = detectKeyTypeFromContent(privateKeyData);
|
||||
const cleanKey = normalizePrivateKeyText(privateKeyData);
|
||||
const fallbackKeyType = detectKeyTypeFromContent(cleanKey);
|
||||
if (fallbackKeyType !== "unknown") {
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
privateKey: cleanKey,
|
||||
publicKey: "",
|
||||
keyType: fallbackKeyType,
|
||||
success: true,
|
||||
@@ -299,13 +337,18 @@ export function parseSSHKey(
|
||||
// expected - fallback key type detection may fail
|
||||
}
|
||||
|
||||
const parserError = error instanceof Error ? error.message : "";
|
||||
const isPuttyKey = PUTTY_PRIVATE_KEY_RE.test(privateKeyData.trim());
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
publicKey: "",
|
||||
keyType: "unknown",
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error parsing key",
|
||||
isPuttyKey && /unsupported|parse|format/i.test(parserError)
|
||||
? getUnsupportedPrivateKeyError(privateKeyData)
|
||||
: parserError || getUnsupportedPrivateKeyError(privateKeyData),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,62 @@ class UserCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
async setupWebAuthnUserEncryption(userId: string): Promise<void> {
|
||||
const existingDEK = this.getUserDataKey(userId);
|
||||
|
||||
if (!existingDEK) {
|
||||
throw new Error(
|
||||
"Cannot enable WebAuthn encryption - user session not active. Please log in first.",
|
||||
);
|
||||
}
|
||||
|
||||
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
||||
const encryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
await this.storeWebAuthnEncryptedDEK(userId, encryptedDEK);
|
||||
}
|
||||
|
||||
async authenticateWebAuthnUser(
|
||||
userId: string,
|
||||
sessionDurationMs: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const encryptedDEK = await this.getWebAuthnEncryptedDEK(userId);
|
||||
if (!encryptedDEK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
||||
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
||||
systemKey.fill(0);
|
||||
|
||||
if (!DEK || DEK.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: Buffer.from(DEK),
|
||||
expiresAt: Date.now() + sessionDurationMs,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("WebAuthn authentication failed", {
|
||||
operation: "webauthn_auth_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
const session = this.userSessions.get(userId);
|
||||
if (!session) {
|
||||
@@ -513,6 +569,21 @@ class UserCrypto {
|
||||
);
|
||||
}
|
||||
|
||||
private deriveWebAuthnSystemKey(userId: string): Buffer {
|
||||
const systemSecret =
|
||||
process.env.WEBAUTHN_SYSTEM_SECRET ||
|
||||
process.env.OIDC_SYSTEM_SECRET ||
|
||||
"termix-webauthn-system-secret-default";
|
||||
const salt = Buffer.from(`webauthn:${userId}`, "utf8");
|
||||
return crypto.pbkdf2Sync(
|
||||
systemSecret,
|
||||
salt,
|
||||
100000,
|
||||
UserCrypto.KEK_LENGTH,
|
||||
"sha256",
|
||||
);
|
||||
}
|
||||
|
||||
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv);
|
||||
@@ -640,6 +711,48 @@ class UserCrypto {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async storeWebAuthnEncryptedDEK(
|
||||
userId: string,
|
||||
encryptedDEK: EncryptedDEK,
|
||||
): Promise<void> {
|
||||
const key = `user_encrypted_dek_webauthn_${userId}`;
|
||||
const value = JSON.stringify(encryptedDEK);
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(settings)
|
||||
.set({ value })
|
||||
.where(eq(settings.key, key));
|
||||
} else {
|
||||
await getDb().insert(settings).values({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
private async getWebAuthnEncryptedDEK(
|
||||
userId: string,
|
||||
): Promise<EncryptedDEK | null> {
|
||||
try {
|
||||
const key = `user_encrypted_dek_webauthn_${userId}`;
|
||||
const result = await getDb()
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key));
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserCrypto, type KEKSalt, type EncryptedDEK };
|
||||
|
||||
+73
-8
@@ -52,6 +52,12 @@ const TmuxMonitorApp = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const HomepageApp = lazy(() =>
|
||||
import("@/features/homepage/HomepageApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
|
||||
const ElectronVersionCheck = lazy(() =>
|
||||
import("@/user/ElectronVersionCheck").then((module) => ({
|
||||
default: module.ElectronVersionCheck,
|
||||
@@ -106,11 +112,61 @@ function FullscreenApp() {
|
||||
case "tmux-monitor": // --- tmux-monitor ---
|
||||
case "tmux_monitor": // tab type spelling, so copied links also resolve
|
||||
return <TmuxMonitorApp hostId={hostId || undefined} />;
|
||||
case "homepage":
|
||||
return <HomepageApp />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FullscreenAppGate() {
|
||||
const { t } = useTranslation();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authFailed, setAuthFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
appReadyPromise
|
||||
.then(() => getUserInfo())
|
||||
.then(async () => {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const token = await getCurrentToken();
|
||||
if (token) localStorage.setItem("jwt", token);
|
||||
} catch {
|
||||
// WebSocket connections can still fall back to cookie auth.
|
||||
}
|
||||
}
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAuthFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (authFailed) {
|
||||
return <FullscreenApp />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <FullscreenApp />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const stored = getStoredAuth();
|
||||
const [phase, setPhase] = useState<Phase>(
|
||||
@@ -118,6 +174,10 @@ function App() {
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track whether fading-in came from a fresh login (vs. session verification on page load).
|
||||
// When session-verified, Auth must not mount during the transition — it would trigger
|
||||
// silent OIDC redirect and cause an infinite refresh loop.
|
||||
const fadingInFromLoginRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedAccent = localStorage.getItem("termix-accent");
|
||||
@@ -139,14 +199,16 @@ function App() {
|
||||
if (phase !== "verifying") return;
|
||||
appReadyPromise
|
||||
.then(() => getUserInfo())
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (isElectron()) {
|
||||
getCurrentToken()
|
||||
.then((token) => {
|
||||
if (token) localStorage.setItem("jwt", token);
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
const token = await getCurrentToken();
|
||||
if (token) localStorage.setItem("jwt", token);
|
||||
} catch {
|
||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||
}
|
||||
}
|
||||
fadingInFromLoginRef.current = false;
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
})
|
||||
@@ -158,6 +220,7 @@ function App() {
|
||||
|
||||
function handleLogin(u: string) {
|
||||
setAuthUsername(u);
|
||||
fadingInFromLoginRef.current = true;
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
if (isElectron()) {
|
||||
@@ -182,7 +245,9 @@ function App() {
|
||||
const showApp =
|
||||
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
||||
const showAuth =
|
||||
phase === "idle-auth" || phase === "fading-in" || phase === "fading-out";
|
||||
phase === "idle-auth" ||
|
||||
(phase === "fading-in" && fadingInFromLoginRef.current) ||
|
||||
phase === "fading-out";
|
||||
const appOpacity = phase === "idle-app" ? 1 : 0;
|
||||
const authOpacity = phase === "idle-auth" ? 1 : 0;
|
||||
|
||||
@@ -259,7 +324,7 @@ function RootApp() {
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<FullscreenApp />
|
||||
<FullscreenAppGate />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+28
@@ -29,6 +29,8 @@ interface DialogResult {
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
|
||||
getServerConfig: () => Promise<ServerConfig>;
|
||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||
@@ -77,6 +79,32 @@ export interface ElectronAPI {
|
||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
|
||||
openExternalEditor: (fileData: {
|
||||
fileName: string;
|
||||
content: string;
|
||||
encoding?: "utf8" | "base64";
|
||||
editorPath?: string | null;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
editId?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
closeExternalEditor: (editId: string) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onExternalEditorSaved?: (
|
||||
callback: (payload: {
|
||||
editId: string;
|
||||
content: string;
|
||||
encoding: "utf8";
|
||||
path: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
export const GRID_SIZE = 30;
|
||||
export const MIN_ZOOM = 0.4;
|
||||
export const MAX_ZOOM = 2.0;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
export type WidgetTypeId =
|
||||
| "service_link"
|
||||
| "folder"
|
||||
| "clock"
|
||||
| "notes"
|
||||
| "host_status"
|
||||
| "bookmark_list"
|
||||
| "weather"
|
||||
| "iframe_embed"
|
||||
| "rss_feed"
|
||||
| "metrics_chart"
|
||||
| "host_grid"
|
||||
| "alert_feed"
|
||||
| "ping_status"
|
||||
| "recent_activity"
|
||||
| "termix_uptime"
|
||||
| "system_overview"
|
||||
| "ssh_terminal"
|
||||
| "quick_connect"
|
||||
| "file_manager_widget"
|
||||
| "docker_widget"
|
||||
| "tunnel_widget"
|
||||
| "calendar"
|
||||
| "countdown"
|
||||
| "search_bar"
|
||||
| "text_banner"
|
||||
| "image_widget"
|
||||
| "markdown_notes"
|
||||
| "custom_api"
|
||||
| "service_grid"
|
||||
| "dashboard_links"
|
||||
| "search_links"
|
||||
| "link_tree";
|
||||
|
||||
export interface HomepageItemRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
typeId: WidgetTypeId;
|
||||
title: string | null;
|
||||
config: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutEntry {
|
||||
itemId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zOrder?: number;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutData {
|
||||
entries: HomepageLayoutEntry[];
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface HomepageLayoutRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
layout: HomepageLayoutData;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CanvasWidget {
|
||||
id: number;
|
||||
typeId: WidgetTypeId;
|
||||
title: string | null;
|
||||
config: Record<string, unknown>;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
zOrder: number;
|
||||
}
|
||||
|
||||
// ---- Per-widget config shapes ----
|
||||
|
||||
export interface ServiceLinkConfig {
|
||||
url: string;
|
||||
description?: string;
|
||||
accentColor?: string;
|
||||
imageUrl?: string;
|
||||
showImage?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderConfig {
|
||||
color?: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export interface ClockConfig {
|
||||
timezone?: string;
|
||||
showSeconds: boolean;
|
||||
format: "12h" | "24h";
|
||||
}
|
||||
|
||||
export interface NotesConfig {
|
||||
content: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export type HostMetricKey =
|
||||
| "cpu"
|
||||
| "memory"
|
||||
| "disk"
|
||||
| "uptime"
|
||||
| "network"
|
||||
| "system"
|
||||
| "processes";
|
||||
|
||||
export interface HostStatusConfig {
|
||||
hostId: number;
|
||||
/** @deprecated use shownMetrics instead */
|
||||
showMetrics?: boolean;
|
||||
/** @deprecated use shownMetrics instead */
|
||||
showDisk?: boolean;
|
||||
shownMetrics: HostMetricKey[];
|
||||
showSparkline?: boolean;
|
||||
}
|
||||
|
||||
export interface BookmarkLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BookmarkListConfig {
|
||||
links: BookmarkLink[];
|
||||
}
|
||||
|
||||
export interface WeatherConfig {
|
||||
location: string;
|
||||
unit: "C" | "F";
|
||||
showForecast: boolean;
|
||||
}
|
||||
|
||||
export interface IframeConfig {
|
||||
url: string;
|
||||
scrolling: boolean;
|
||||
}
|
||||
|
||||
export interface RssFeedConfig {
|
||||
feedUrl: string;
|
||||
maxItems: number;
|
||||
showDescription: boolean;
|
||||
}
|
||||
|
||||
// ---- New widget configs ----
|
||||
|
||||
export type MetricsChartMetric =
|
||||
| "cpu"
|
||||
| "memory"
|
||||
| "disk"
|
||||
| "net_rx"
|
||||
| "net_tx";
|
||||
export type MetricsChartRange = "15m" | "1h" | "6h" | "24h";
|
||||
|
||||
export interface MetricsChartConfig {
|
||||
hostId: number;
|
||||
metric: MetricsChartMetric;
|
||||
range: MetricsChartRange;
|
||||
showCurrentValue: boolean;
|
||||
}
|
||||
|
||||
export interface HostGridConfig {
|
||||
hostIds: number[];
|
||||
showIp: boolean;
|
||||
columns: 2 | 3 | 4;
|
||||
}
|
||||
|
||||
export interface AlertFeedConfig {
|
||||
maxItems: number;
|
||||
showAcknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface PingUrl {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PingStatusConfig {
|
||||
urls: PingUrl[];
|
||||
refreshInterval: number;
|
||||
showLatency: boolean;
|
||||
}
|
||||
|
||||
export type ActivityType =
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "docker"
|
||||
| "tunnel"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
|
||||
export interface RecentActivityConfig {
|
||||
maxItems: number;
|
||||
filterTypes: ActivityType[];
|
||||
showTimestamp: boolean;
|
||||
}
|
||||
|
||||
export interface TermixUptimeConfig {
|
||||
showDetailed: boolean;
|
||||
}
|
||||
|
||||
export interface SystemOverviewConfig {
|
||||
showVersion: boolean;
|
||||
showDbHealth: boolean;
|
||||
showUptime: boolean;
|
||||
}
|
||||
|
||||
export interface FileManagerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface DockerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface TunnelWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export interface SshTerminalConfig {
|
||||
hostId: number;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
export type QuickConnectType =
|
||||
| "terminal"
|
||||
| "files"
|
||||
| "docker"
|
||||
| "tunnel"
|
||||
| "host-metrics"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
|
||||
export interface QuickConnectConfig {
|
||||
hostIds: number[];
|
||||
connectionTypes: QuickConnectType[];
|
||||
showStatus: boolean;
|
||||
layout: "grid" | "list";
|
||||
}
|
||||
|
||||
export interface CalendarConfig {
|
||||
timezone?: string;
|
||||
startOnMonday: boolean;
|
||||
}
|
||||
|
||||
export interface CountdownConfig {
|
||||
targetDate: string;
|
||||
label: string;
|
||||
showDays: boolean;
|
||||
showHours: boolean;
|
||||
}
|
||||
|
||||
export type SearchEngine = "google" | "duckduckgo" | "bing" | "custom";
|
||||
|
||||
export interface SearchBarConfig {
|
||||
engine: SearchEngine;
|
||||
customUrl?: string;
|
||||
placeholder?: string;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
|
||||
export type TextBannerFontSize = "sm" | "md" | "lg" | "xl";
|
||||
export type TextBannerAlign = "left" | "center" | "right";
|
||||
export type TextBannerWeight = "normal" | "semibold" | "bold";
|
||||
|
||||
export interface TextBannerConfig {
|
||||
text: string;
|
||||
fontSize: TextBannerFontSize;
|
||||
textAlign: TextBannerAlign;
|
||||
fontWeight: TextBannerWeight;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export interface ImageWidgetConfig {
|
||||
imageUrl: string;
|
||||
fit: "contain" | "cover" | "fill";
|
||||
alt?: string;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
export interface MarkdownNotesConfig {
|
||||
content: string;
|
||||
backgroundColor?: string;
|
||||
renderMarkdown: boolean;
|
||||
}
|
||||
|
||||
export type CustomApiDisplayMode = "value" | "json" | "table";
|
||||
|
||||
export interface CustomApiConfig {
|
||||
url: string;
|
||||
displayField?: string;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
refreshInterval: number;
|
||||
displayMode: CustomApiDisplayMode;
|
||||
jsonPath?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGridItem {
|
||||
label: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export interface ServiceGridConfig {
|
||||
services: ServiceGridItem[];
|
||||
columns: 2 | 3 | 4;
|
||||
showLabels: boolean;
|
||||
iconSize: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export interface DashboardLinksConfig {
|
||||
showIcons: boolean;
|
||||
columns: 1 | 2 | 3;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export interface SearchLinkShortcut {
|
||||
label: string;
|
||||
queryTemplate: string;
|
||||
icon?: string;
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export interface SearchLinksConfig {
|
||||
shortcuts: SearchLinkShortcut[];
|
||||
}
|
||||
|
||||
export interface LinkTreeLink {
|
||||
label: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface LinkTreeSection {
|
||||
heading: string;
|
||||
links: LinkTreeLink[];
|
||||
}
|
||||
|
||||
export interface LinkTreeConfig {
|
||||
sections: LinkTreeSection[];
|
||||
compact: boolean;
|
||||
}
|
||||
|
||||
// ---- Widget registry types ----
|
||||
|
||||
export interface WidgetComponentProps<C = Record<string, unknown>> {
|
||||
widget: CanvasWidget;
|
||||
config: C;
|
||||
isReadOnly?: boolean;
|
||||
onConfigUpdate?: (config: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C = Record<string, unknown>> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
export interface WidgetTypeDefinition<C = Record<string, unknown>> {
|
||||
id: WidgetTypeId;
|
||||
name: string;
|
||||
description: string;
|
||||
category: "links" | "info" | "system" | "monitoring";
|
||||
icon: React.ReactNode;
|
||||
defaultConfig: C;
|
||||
defaultSize: { w: number; h: number };
|
||||
minSize: { w: number; h: number };
|
||||
component: React.ComponentType<WidgetComponentProps<C>>;
|
||||
editFormComponent?: React.ComponentType<WidgetEditFormProps<C>>;
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
widgetId: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startWidgetX: number;
|
||||
startWidgetY: number;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
widgetId: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startW: number;
|
||||
startH: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuAnchor {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
anchorRect?: ContextMenuAnchor;
|
||||
}
|
||||
@@ -21,7 +21,9 @@ export type HostMetricManagerId =
|
||||
| "health_check"
|
||||
| "disk_breakdown"
|
||||
| "systemd_timers"
|
||||
| "top_memory";
|
||||
| "top_memory"
|
||||
| "wireguard_manager"
|
||||
| "tailscale_manager";
|
||||
|
||||
export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId;
|
||||
|
||||
@@ -51,6 +53,7 @@ export const METRIC_CARD_IDS: HostMetricCardId[] = [
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
];
|
||||
|
||||
export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
|
||||
@@ -66,6 +69,8 @@ export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
|
||||
"disk_breakdown",
|
||||
"systemd_timers",
|
||||
"top_memory",
|
||||
"wireguard_manager",
|
||||
"tailscale_manager",
|
||||
];
|
||||
|
||||
export function isMetricCardId(id: string): id is HostMetricCardId {
|
||||
@@ -96,6 +101,7 @@ const DEFAULT_COLSPAN: Partial<Record<HostMetricsCardId, HostMetricsColSpan>> =
|
||||
uptime: 1,
|
||||
system: 1,
|
||||
network: 1,
|
||||
temperature: 1,
|
||||
processes: 2,
|
||||
ports: 2,
|
||||
firewall: 2,
|
||||
|
||||
+44
-2
@@ -102,7 +102,14 @@ export interface Host {
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
||||
authType:
|
||||
| "password"
|
||||
| "key"
|
||||
| "credential"
|
||||
| "none"
|
||||
| "opkssh"
|
||||
| "tailscale"
|
||||
| "agent";
|
||||
useWarpgate?: boolean;
|
||||
password?: string;
|
||||
key?: string;
|
||||
@@ -161,6 +168,7 @@ export interface Host {
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: string | Record<string, unknown>;
|
||||
dockerConfig?: Record<string, unknown> | null;
|
||||
|
||||
enableSsh?: boolean;
|
||||
enableRdp?: boolean;
|
||||
@@ -221,7 +229,14 @@ export interface HostData {
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
||||
authType:
|
||||
| "password"
|
||||
| "key"
|
||||
| "credential"
|
||||
| "none"
|
||||
| "opkssh"
|
||||
| "tailscale"
|
||||
| "agent";
|
||||
useWarpgate?: boolean;
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
@@ -530,6 +545,7 @@ export interface FileItem {
|
||||
sshSessionId?: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
modifiedTimestamp?: number;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
@@ -622,6 +638,30 @@ export interface TerminalConfig {
|
||||
allowLegacyAlgorithms?: boolean;
|
||||
linkClickBehavior?: "confirm" | "direct";
|
||||
useSSHTitle?: boolean;
|
||||
agentSocketPath?: string;
|
||||
customThemeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -656,6 +696,7 @@ export interface TabContextTab {
|
||||
export interface TerminalRefHandle {
|
||||
disconnect?: () => void;
|
||||
reconnect?: () => void;
|
||||
isConnected?: () => boolean;
|
||||
fit?: () => void;
|
||||
sendInput?: (data: string) => void;
|
||||
notifyResize?: () => void;
|
||||
@@ -1045,6 +1086,7 @@ export interface DockerLogOptions {
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
runtime?: "docker" | "podman";
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export type WidgetType =
|
||||
| "system"
|
||||
| "login_stats"
|
||||
| "ports"
|
||||
| "firewall";
|
||||
| "firewall"
|
||||
| "temperature";
|
||||
|
||||
export interface ListeningPort {
|
||||
protocol: "tcp" | "udp";
|
||||
@@ -49,6 +50,17 @@ export interface FirewallMetrics {
|
||||
chains: FirewallChain[];
|
||||
}
|
||||
|
||||
export interface TemperatureSensor {
|
||||
label: string;
|
||||
celsius: number;
|
||||
}
|
||||
|
||||
export interface TemperatureMetrics {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: TemperatureSensor[];
|
||||
}
|
||||
|
||||
export interface StatsConfig {
|
||||
enabledWidgets: WidgetType[];
|
||||
statusCheckEnabled: boolean;
|
||||
@@ -72,6 +84,7 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user