release-2.5.0 (#994)

* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

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

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

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

* feat: allow custom tunnel endpoints (#977)

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

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

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

* Prevent sidebar host hover layout shift (#970)

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

* Add host temperature metrics card (#972)

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

* Add app rail hover expansion setting (#974)

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

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

* fix: SSH port connection bug (#975)

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

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

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

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

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

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

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

* Fix TUI terminal output highlighting (#983)

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

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

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

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
+6
View File
@@ -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"),
+415 -2
View File
@@ -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",
});
+275 -1
View File
@@ -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 ---
+16 -1
View File
@@ -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) {
+5 -5
View File
@@ -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" });
}
});
+121 -2
View File
@@ -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"
+288 -29
View File
@@ -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) {
+10
View File
@@ -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;
}
}
+12 -1
View File
@@ -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 });
},
);
}
+47 -26
View File
@@ -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:
+450
View File
@@ -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;