release-2.4.0 (#913)

* feat: continued adding features to the release.yml

* feat: added auto youtube submit

* feat: add dry run to release workflow

* feat: improve release workflow mode system with new dropdown and overwrite release mode

* fix: make credentials start collapsed

* fix: make hostname fill entire column and truncate at proper spot in dashboard

* fix: guacamole display using an incorrect height

* feat: add folder management (nested folders, folder icons, folder colors, improved folder selection, etc.)

* feat: move ssh host config outside of top tab bar and into new tab bar visible when on SSH tab

* fix: 2FA failing to disable

* feat: Proxmox guest discovery and import (#881)

* feat(db): add Proxmox host fields and schema migration

Add enableProxmox flag and proxmoxConfig JSON to the hosts table so an
SSH host can be marked as a Proxmox node and carry per-host discovery
settings (default credential, Windows/Docker name patterns, preferred
IP prefixes). Includes addColumnIfNotExists migration entries and the
matching SSHHost / HostData / Host type definitions.

* feat(api): add Proxmox guest discovery endpoint

Add POST /proxmox/discover which connects to a Proxmox node over SSH
using the host's existing stored credentials (no separate API token),
runs pvesh to enumerate cluster guests, and returns VMs and LXC
containers ready to import. Guest IPs are resolved with bounded
concurrency (LXC from net config, QEMU via guest agent) to stay within
the node's SSH MaxSessions; configured preferred prefixes pick the
address when a guest has several. Name patterns flag Windows guests as
RDP and enable Docker. Node names are validated before use in shell
commands and the SSH connection is always closed via try/finally. The
endpoint carries an OpenAPI annotation. Persists the new host fields
across host create/update/select, the normalizers (transformHostResponse)
and the bulk routes, with normalizer test coverage.

* feat(ui): add Proxmox host settings and guest import

Add a Proxmox tab to the host editor (following the Docker tab pattern)
to enable the integration and configure the default credential, name
patterns and preferred IP ranges. Proxmox-enabled hosts show a discover
action in the sidebar that opens an import dialog listing the cluster's
guests with multi-select; the import reuses the host's credential, maps
Windows guests to RDP, and groups imported guests into a folder named
after the Proxmox host. Also exposes the import via the hosts
import/export menu. All user-facing strings are added to the en.json
locale and rendered via i18n. Built with the existing shadcn Select,
dialog and section-card components to match the dark theme.

* feat(ui): add Proxmox to the bulk feature toggle

Proxmox was the only host feature missing from the multi-select
"Features" menu. Enabling it in bulk is special: unlike the other
toggles it needs a credential to be useful, so each host now defaults
its Proxmox credential to the credential already stored on that host
(host.credentialId). Discovery then works right away without picking
one by hand, and per-host settings can still be adjusted afterwards.

- SidebarTree: Enable/Disable Proxmox entries (Boxes icon) in the
  bulk Features dropdown
- host-bulk-routes: enabling Proxmox runs per-host so each host keeps
  its own default credential; existing proxmoxConfig is preserved;
  disabling stays a simple flag flip
- en.json: enableProxmoxFeature / disableProxmoxFeature

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix: reliable OIDC group syncing for admin roles (#882)

- always fetch from the userinfo endpoint to get extra claims (like `groups` from Authelia) instead of skipping it if the id_token was already parsed
- handle cases where the OIDC provider sends groups as a single string, a comma-separated string
- add debug logging

* fix: incorrect guacd screen height

* fix: guacd black screen on connnection

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* feat: initial server stats rewrite (renamed to host metrics)

* feat: continue renaming and improving host metrics features and UI

* fix: clipboard copy fails in Brave/non-HTTPS, dashboard total credentials stuck at 0, host username ignored when credential attached, cloned host can't switch auth method, file-manager context menu off-screen, file delete affecting inactive tabs, silent delete failure on Windows hosts, iPad host tap does nothing, OIDC custom group claim support

* chore: update dependabot for my app and add auto merge/branch update

* chore: update dependabot for my app and add auto merge/branch update

* Feat/docker release tag (#894)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* feat(docker): add version tag to production Docker image tags

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: disable hardware acceleration on Windows to prevent startup crash (#895)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* fix: disable hardware acceleration on Windows to prevent startup crash

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* chore(deps-dev): bump the dev-patch-updates group across 1 directory with 23 updates (#893)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps-dev): bump the dev-patch-updates group across 1 directory with 23 updates

Bumps the dev-patch-updates group with 22 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/autocomplete](https://github.com/codemirror/autocomplete) | `6.20.2` | `6.20.3` |
| [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) | `21.0.1` | `21.0.2` |
| [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional) | `21.0.1` | `21.0.2` |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.12` | `1.2.13` |
| [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.3` | `1.3.4` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.16` | `2.1.17` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.9` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.15` | `1.1.16` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.8` | `1.1.9` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.10` | `1.2.11` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.9` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.13` | `1.1.14` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.9` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `25.9.2` |
| [i18next](https://github.com/i18next/i18next) | `26.3.0` | `26.3.1` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.5` | `17.0.7` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.14` | `8.0.16` |



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

Updates `@commitlint/cli` from 21.0.1 to 21.0.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.0.2/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.0.1 to 21.0.2
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.0.2/@commitlint/config-conventional)

Updates `@radix-ui/react-accordion` from 1.2.12 to 1.2.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion)

Updates `@radix-ui/react-alert-dialog` from 1.1.15 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog)

Updates `@radix-ui/react-checkbox` from 1.3.3 to 1.3.4
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-dialog` from 1.1.15 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.16 to 2.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.8 to 2.1.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.15 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.8 to 1.1.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-scroll-area` from 1.2.10 to 1.2.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-separator` from 1.1.8 to 1.1.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.4 to 1.2.5
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-tabs` from 1.1.13 to 1.1.14
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-tooltip` from 1.2.8 to 1.2.9
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

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

Updates `i18next` from 26.3.0 to 26.3.1
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.0...v26.3.1)

Updates `lint-staged` from 17.0.5 to 17.0.7
- [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.5...v17.0.7)

Updates `react` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react)

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

Updates `react-dom` from 19.2.6 to 19.2.7
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.7/packages/react-dom)

Updates `vite` from 8.0.14 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: "@codemirror/autocomplete"
  dependency-version: 6.20.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/cli"
  dependency-version: 21.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-alert-dialog"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump undici from 7.26.0 to 8.4.0 (#891)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump undici from 7.26.0 to 8.4.0

Bumps [undici](https://github.com/nodejs/undici) from 7.26.0 to 8.4.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.26.0...v8.4.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group with 2 updates (#890)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump the prod-minor-updates group with 2 updates

Bumps the prod-minor-updates group with 2 updates: [axios](https://github.com/axios/axios) and [js-yaml](https://github.com/nodeca/js-yaml).


Updates `axios` from 1.16.1 to 1.17.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.17.0)

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

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/setup-node from 4 to 6 (#887)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/setup-node from 4 to 6

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/checkout from 4 to 6 (#886)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/checkout from 4 to 6

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [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/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/upload-artifact from 4 to 7 (#885)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/upload-artifact from 4 to 7

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump actions/download-artifact from 4 to 8 (#884)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* chore(deps): bump actions/download-artifact from 4 to 8

Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: automate rebasing dependabot

* feat: add admin settings audit log feature

* feat: add customizability of app rail tabs

* fix: host metrics giving sudo password error

* feat: add terminal session logging

* feat: sso system redesign (multiple oidc, ldap support, etc.)

* feat: tailscale auth support

* feat: improve syntax highlighting system

* feat: improve nested folder system/ui

* feat: improve nested folder system/ui

* fix: move tailscale i18n and add scrollbar to ssh host manager customization

* feat: improve syntax highlighting, fix tailscale bugs and host manger UX

* fix: admin page not loading user admin information

* chore: update crowdin workflow

* fix: guacd getting passed incorrect protocol for app view

* fix: add SFTP jump-host fallback from host data (#902)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* fix: add SFTP jump-host fallback from host data

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

* feat(tmux-monitor): add per-host tmux session monitor (#896)

* fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)

* Revert "fix(docker): use resolveTermixThemeColors for Docker console terminal background (#883)"

This reverts commit 4d04559ca5.

* feat(tmux-monitor): add per-host tmux session monitor

New isolated feature module following the docker/server-stats pattern:

- Backend Express service on port 30010 (src/backend/ssh/tmux-monitor.ts)
  listing tmux sessions/windows/panes, pane capture, cross-session
  output search, and per-pane CPU/RAM/GPU metrics over pooled SSH
  connections. Read-only; reuses host-resolver, jump-host-chain,
  connection pool and RBAC access checks.
- tmux_session_tags table for per-user project tags on sessions.
- Frontend fullscreen app at ?view=tmux-monitor with host selector,
  session/window/pane tree, live pane preview (2s polling), search
  bar, metrics display, and tag editing.
- nginx location blocks for /tmux_monitor in Docker deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): live streaming backend and shell integration

- WebSocket service on port 30011 (tmux-monitor-live.ts) bridging tmux
  control mode (-C attach-session) to the browser: subscribe per pane,
  decoded %output events streamed as JSON, structure-change and detach
  notifications. Pure control-mode parser with 17 unit tests.
- Register tmux_monitor as a singleton shell tab: AppRail entry below
  Network Graph, global Command Palette action, and per-host palette
  action (gated on enableTerminal) that opens with the host preselected.
- nginx WebSocket location /tmux_monitor/live/ for Docker deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): xterm live preview and UX polish

- Pane preview is now a read-only xterm.js terminal with ANSI colors
  (capture-pane -e via new ansi=1 param) fed by the live WebSocket;
  status chip shows live/connecting/polling and the original 2s REST
  polling remains as automatic fallback.
- useTmuxLive hook: survives React StrictMode remounts (unmounted flag
  reset on mount), force-closes sockets stuck in CONNECTING after 8s so
  retry/fallback always engages, uses import.meta.env.DEV for dev
  detection.
- UX: skeleton loading, error state with retry, actionable empty states,
  persisted host/expanded-session selection, "/" and Escape shortcuts,
  search match highlighting, fresh relative timestamps.

Verified end-to-end in Chrome against a local backend: live chip goes
green and pane output streams in real time over the control-mode bridge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tmux-monitor): replace control-mode bridge with native PTY attach

The pane preview now embeds the real Terminal component and attaches to
the selected session through the existing terminal WebSocket (new
tmuxAttachSession handshake field in ssh/terminal.ts), making the
preview fully interactive — typing, mouse scrolling and tmux copy-mode
behave exactly like a normal terminal tab. Pane clicks within a session
use the new /focus endpoint (select-window/select-pane) instead of
remounting the terminal.

This removes the entire custom live-streaming stack: the control-mode
WebSocket service on port 30011 (tmux-monitor-live.ts), the control
sequence parser and its tests, the useTmuxLive hook, the nginx
/tmux_monitor/live/ location blocks, and the now-unused live/polling
status chip locale keys.

Verified end-to-end against a dockerized SSH host (Alpine + tmux 3.6):
session tree, interactive preview with ANSI colors, cross-session
search with jump-to-pane, per-pane CPU/RAM metrics, session tags, and
attach-in-new-tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): use printable field separator in tmux format strings

The unit separator control character (\x1f) used to delimit fields in
tmux -F output gets sanitized to "_" by tmux itself — tmux replaces
control characters (and, under non-UTF-8 locales, any multibyte
character) in expanded format output. On such hosts every list-sessions
line came back as a single unsplittable field, so the monitor showed
session names like "build_1781066459_1781066459_0" and windows/panes
failed to parse entirely.

Switch SEP to the printable ASCII token "<<TMX>>", which survives every
tmux version and locale. Reproduced and verified against tmux 3.6 on
Alpine (C locale) in a docker container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): create sessions and split panes from the monitor

- POST /tmux_monitor/:hostId/sessions creates a detached session
  (tmux new-session -d), starting the tmux server if needed. Session
  names are validated against a conservative charset on both ends
  (tmux forbids ":" and "."), and a duplicate name returns 409 with
  tmux's own message surfaced in the toast.
- POST /tmux_monitor/:hostId/split splits the window containing a pane
  (tmux split-window -h/-v), with the new pane starting in the source
  pane's working directory via -c '#{pane_current_path}'.
- UI: a "+" button next to the host selector opens a name popover
  (Enter or Create); the pane preview header gains split-right and
  split-down buttons. Splits appear instantly in the attached preview
  terminal and a silent overview reload updates the tree; new sessions
  are auto-expanded.

Command syntax verified against tmux 3.6 in a dockerized SSH host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): VSCode tmux-manager style tree with window actions

Restyle the session tree after the VSCode tmux manager view:

- Header toolbar: new-session (+) popover and refresh moved next to the
  panel title; the host selector row is back to a plain full-width
  select.
- Session rows gain hover actions — attach (play) and new window (+) —
  revealed with the opacity pattern used by docker's ContainerCard so
  they stay keyboard-focusable, with aria-labels on all icon buttons.
- Window rows are collapsible (chevron, "index: name" with a window
  icon); panes show "index: command" plus the pane's working directory
  in muted text with a full-path tooltip.
- New POST /tmux_monitor/:hostId/windows route runs tmux new-window
  against an exact-match ("=name") shell-escaped target; rejects names
  containing ":"/"."/newline and returns 404 for missing sessions.
- Pane preview header groups the split buttons apart from close with a
  separator.

Reviewed by SA/BA/UX subagent panel; keyboard accessibility and
aria-label findings applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): rename/kill sessions, collapse-all, no-flash refresh

- Session rows gain a "…" menu with Rename (dialog, validated name) and
  Kill (destructive confirmation). Rename migrates saved tags to the
  new name and re-attaches an open preview; kill closes the preview and
  drops the session's tags. Both routes use exact-match ("=name")
  shell-escaped targets and map tmux's "can't find session" /
  "duplicate session" errors to 404/409.
- Header gains a VSCode-style collapse-all / expand-all toggle for the
  session tree.
- Refresh UX: manual refresh no longer blanks the tree into skeletons —
  the skeleton is reserved for a host's first load; refresh keeps the
  tree, spins the icon (header and toolbar) and reports failures with a
  toast instead of silently keeping stale data.

tmux command syntax verified against tmux 3.6 in the docker test host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): single-row sessions, overlay hover actions, tag dialog

UI cleanup after desktop testing showed the tree looked cluttered:

- Sessions are now a single row. The permanently visible tags row (a
  mostly empty line with a lone tag icon under every session) is gone:
  tags render as inline badges after the name (max 2, then "+n" with a
  full-list tooltip) and editing moved to the "…" menu, which opens a
  dialog like rename.
- Session metrics merged into the right-hand meta label
  ("just now · 0% · 7 MB"). Hover actions (attach, new window, …) are
  stacked over that label in a grid cell with an opacity cross-fade, so
  the row never shifts and the buttons remain keyboard-focusable
  (focus-within reveals them).
- Dropdown items no longer wrap: short labels (Rename / Edit tags /
  Kill), whitespace-nowrap, min-w-36, align="end".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): kill pane; fix session actions in the desktop app

- Kill pane: hover ✕ on pane rows and a trash button in the preview
  header, both behind a confirmation dialog (kill-pane terminates the
  pane's process; the last pane closes its window). New
  POST /tmux_monitor/:hostId/kill-pane route with pane-id validation.
- Fix attach (▷) doing nothing in the Electron build: window.open of
  internal file:// URLs is denied by the window-open handler, so on
  desktop the button now attaches inline — expands the session and
  selects its active pane in the embedded terminal. Browsers keep the
  open-in-new-tab behavior.
- Enlarge the hover-action hit areas (-m-1 p-1) so clicks land on the
  buttons instead of toggling the session row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): session hover actions were unclickable

The meta label (time · cpu · mem) fades to opacity-0 on hover, which
turns it into a stacking context painted above its plain-flow sibling —
the invisible label sat on top of the attach/new-window/menu buttons
and swallowed their clicks, so clicking them toggled the session row
instead. Give the actions overlay z-10 and make the label
pointer-events-none.

Reproduced and verified in Chrome with elementFromPoint before/after;
create → rename → kill all work end-to-end through the UI now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): resizable session tree panel

The tree panel was fixed at w-72; long session names and pane paths had
no room. Its right edge is now a drag handle (200–520px, same pattern
and styling as the AppShell host sidebar), double-click resets to the
default width, and the width persists in localStorage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): split buttons on pane rows

Pane rows' hover actions gain split-right and split-down next to the
kill button, so a pane can be split straight from the tree. splitPane
now takes the target pane id instead of implicitly using the selected
pane; the preview header passes its own pane through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): repair garbled preview after layout changes

External split / kill-pane / new-window actions can leave the attached
preview client with stale borders (the redraw races the xterm size).
Two-part fix:

- After any layout-changing action the monitor nudges the embedded
  terminal (fit + notifyResize + refresh) — the PTY resize forces tmux
  to fully redraw the client.
- The preview header gains a re-attach button that remounts the
  terminal with a fresh PTY as a one-click rescue when the view is
  garbled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): kill window, compact mode, session status tooltip

- Kill window: hover ✕ on window rows behind a confirmation dialog
  (kills every pane; the last window ends the session). New
  POST /tmux_monitor/:hostId/kill-window route with exact-match target
  and 404 mapping.
- Narrow tree panel (<280px) hides the inline "time · cpu · mem" label,
  which used to crush long session names into "bui…".
- Hovering a session row now shows a status board tooltip (attached
  state, activity, window/pane counts, CPU/RAM/GPU, tags) — the numbers
  stay reachable in compact mode and richer in normal mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): keep preview attached after killing the viewed pane

Killing the selected pane (or its window) dumped the user back to the
"select a pane" empty state even though the session was still alive and
the attached terminal had already focused a sibling pane. After the
kill the monitor now refetches the overview and selects the session's
new active pane, so the preview stays on the same attach connection
(same session key → no remount); the empty state only appears when the
whole session died with the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): require execute permission, audit destructive actions, persist tag mutations

- Mutating endpoints (kill/kill-window/kill-pane/rename/split/focus,
  create session/window) now require "execute" host access like docker;
  read-only shared hosts could previously kill sessions and spawn shells
- Log kill/rename actions to the audit log (same pattern as host.ts)
- Trigger a DB save after tag updates in rename/kill handlers; the dirty
  flag was never set, so a crash could lose or resurrect tags
- Add host_id -> ssh_data FK with ON DELETE CASCADE to tmux_session_tags
  (drizzle schema + SQL migration), with a rebuild migration for
  pre-release tables created without the FK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): stop polling and key handling while the tab is hidden, guard stale host responses

- Pass isVisible into TmuxMonitor (like DockerManager/HostMetricsTab);
  the global "/"+Escape keydown listener and both SSH polling loops now
  pause while the tab is mounted but hidden
- Split the overview polling interval out of the host-change effect so
  resuming a tab does not reset state or flash the skeleton
- Ignore overview responses for a host the user has switched away from;
  a slow response could overwrite the selected host's tree
- Format SearchResults/SessionTree/TmuxMonitorApp/SidebarTree with
  prettier (pr-check was failing on these)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): show an empty state when no SSH hosts exist

With zero hosts the host dropdown opened an empty popover and the
preview area showed the unrelated "select a pane" hint. Disable the
select (its placeholder now says no hosts are available) and show a
central no-hosts message with a pointer to the Host Manager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): refresh the host list when hosts change elsewhere

The list was fetched once on mount, but the tab stays mounted forever,
so adding the first host in Host Manager left the monitor stuck on
"no hosts" until the tab was closed and reopened. Listen to the
app-wide termix:hosts-changed event (same as AppShell/HostManager),
keep the current selection when still valid, auto-select the first
host when one appears, and clear the session tree when the selected
host is deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): keep pane/window actions reachable on a narrow tree

Pane rows kept the split/kill buttons and the "index: command" label at
fixed width, so dragging the panel toward the 200px minimum pushed the
buttons past the right edge; Radix ScrollArea's display:table viewport
wrapper made it worse by stretching every row to the widest pane path,
clipping the window kill button too.

Stack the cpu label and the hover actions in one grid cell (the same
overlay pattern as the session rows), let the command and path labels
truncate, and force the viewport wrapper back to display:block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): reskin the session status tooltip as a popover card

The base TooltipContent is the inverted one-line chip (bg-foreground,
sharp corners, arrow), so the multi-row session board inherited a light
surface that washed out its muted-foreground labels and clashed with
every other floating card in the app. Restyle this one instance with the
popover surface tokens and hide the chip arrow (important is needed
because Radix sets display on the arrow svg inline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): disable new-session and search when tmux is missing

On a host without tmux the + button still opened the create popover
(submitting just surfaced the backend TMUX_NOT_INSTALLED toast) and
search silently returned "0 matches". Gate both on overview.available
like the neighboring refresh/expand buttons; the search input carries
the unavailable message as its tooltip.

Verified against a real no-tmux host (alpine+sshd container): both
controls disable on it and stay enabled on a host with tmux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): restyle floating surfaces to match the square UI

Review feedback on #896: the host selector, the new-session popover,
the session status tooltip and the three kill confirmations rendered
with the stock rounded shadcn look while the rest of the app moved to
square corners, ring borders and blurred dialog overlays. Convert the
kill confirmations from AlertDialog to the already-restyled Dialog and
override the remaining surfaces with rounded-none + ring-border.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(host-editor): format the tailscale device options with project prettier

The block arrived unformatted in the dev-2.4.0 merge; committing the
reformat separately keeps the tmux-monitor commits free of unrelated
churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tmux-monitor): per-host opt-in flag, disabled by default

Review feedback on #896: the tmux actions appeared on every SSH host's
sidebar entry even though most users do not use tmux. Add an
enable_tmux_monitor column (default off) mirroring the enableDocker
plumbing, expose it as an "Enable Tmux Monitor" toggle in the host
editor's Terminal tab, and gate every per-host tmux surface on it:
the sidebar hover action and copy-URL menu item, the command palette
entry, the monitor's host selector, and the tmux-monitor API itself
(refusing like docker.ts does when the feature is off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): drop the redundant navbar rail entry

The monitor is per-host opt-in, so the global rail item opened a
host-less view. Hosts with enableTmuxMonitor still expose it via the
host action in the sidebar tree and the command palette.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tmux-monitor): fill the status popover card width

TooltipContent is an inline-flex container, so the inner block sized
to its natural content width and left a dead gutter on the right of
the w-56 card. Stretch it with w-full so the values align to the edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: remove unused dropdown, seperate 3 line menu, and fix a few small UI bugs

---------

Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: add i18n for tmux

* feat: continue improving terminal highlighint with more options and more consistency

* feat: add per host configuration of history tracking with overarching admin toggle

* feat: add storage preference to user profile settings (local or DB)

* chore: update release notes

* fix: return user password reset code to logs

* fix: missing tailscale errors and i18n

* fix: sso/browser preference fixes

* fix: remove SSL_ENABLED references

* feat: add filter/sort to credentials list and fixed some JSDoc comments

* chore: update release notes

* chore: update readmes

* chore: update readmes and add docs links

* feat: continue improving SSO system

* fix: host metrics bugs

* fix: session logging api endpoint fail

* chore: make electron.yml auto deliver to macos app store

* chore: fix linting issues

* chore: fix linting issues

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

* chore: crowdin using incorrect api key name

* chore: sync Crowdin translations for 2.4.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: XtraLarge <eMail@WilliWerres.de>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: verybadsoldier <364469+verybadsoldier@users.noreply.github.com>
Co-authored-by: i/o agent <273482092+claw-io@users.noreply.github.com>
Co-authored-by: Félix MARQUET <72651575+BreizhHardware@users.noreply.github.com>
Co-authored-by: Rabindra Kumar Meher <rabindrameher116@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jose Tenggren <kf16el42@students.muetkhp.edu.pk>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Kang <78076796+khengyun@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-06-16 15:59:53 -05:00
committed by GitHub
parent 580c284065
commit ac24fdcba8
297 changed files with 111507 additions and 74240 deletions
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { pickResolvedUsername } from "./credential-username.js";
describe("pickResolvedUsername", () => {
it("keeps the host username when one is set, even with a credential username", () => {
expect(pickResolvedUsername("admin", "root", false)).toBe("admin");
});
it("falls back to the credential username when the host has none", () => {
expect(pickResolvedUsername("", "root", false)).toBe("root");
expect(pickResolvedUsername(undefined, "root", false)).toBe("root");
expect(pickResolvedUsername(" ", "root", false)).toBe("root");
});
it("treats whitespace-only host usernames as empty", () => {
expect(pickResolvedUsername(" ", "deploy", false)).toBe("deploy");
});
it("forces the host username when overrideCredentialUsername is set", () => {
expect(pickResolvedUsername("admin", "root", true)).toBe("admin");
expect(pickResolvedUsername("", "root", true)).toBeUndefined();
});
it("returns undefined when neither username is usable", () => {
expect(pickResolvedUsername("", "", false)).toBeUndefined();
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Decides which username to use when a host is backed by a saved credential.
*
* An explicitly-set host username always wins - if the user typed a username on
* the host it should be honoured even when a credential is attached. The
* credential's username is only used as a fallback when the host has none. The
* `overrideCredentialUsername` flag forces the host username regardless.
*/
export function pickResolvedUsername(
hostUsername: unknown,
credentialUsername: unknown,
overrideCredentialUsername?: unknown,
): string | undefined {
const host = isNonEmptyString(hostUsername) ? hostUsername : undefined;
const cred = isNonEmptyString(credentialUsername)
? credentialUsername
: undefined;
if (overrideCredentialUsername) return host;
if (host) return host;
return cred;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
+16 -6
View File
@@ -314,7 +314,9 @@ async function createJumpHostChain(
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: jumpHostConfig.authType !== "none",
tryKeyboard:
jumpHostConfig.authType !== "none" &&
jumpHostConfig.authType !== "tailscale",
readyTimeout: 60000,
hostVerifier: jumpHostVerifier,
algorithms: {
@@ -848,8 +850,11 @@ app.post("/docker/ssh/connect", async (req, res) => {
),
};
if (resolvedCredentials.authType === "none") {
// no credentials needed
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
// Tailscale SSH and "none" auth: no credentials needed
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
@@ -1008,7 +1013,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH key"),
);
} else if (resolvedCredentials.authType === "none") {
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
connectionLogs.push(
createConnectionLog(
"info",
@@ -1144,7 +1152,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
}
if (
resolvedCredentials.authType === "none" &&
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
@@ -1305,7 +1314,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
);
if (
resolvedCredentials.authType === "none" &&
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
@@ -449,7 +449,7 @@ export function registerFileOperationRoutes(
return;
}
if (outputData.includes("SUCCESS") || code === 0) {
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
@@ -465,8 +465,22 @@ export function registerFileOperationRoutes(
},
});
} else {
// The shell didn't echo our SUCCESS sentinel. This happens on
// non-POSIX shells (e.g. Windows PowerShell, where `rm -f` is
// not supported) and the command can exit 0 while doing nothing.
// Surface whatever the shell produced so the failure isn't silent.
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Command failed: ${errorData}`,
error: `Delete failed: ${detail}`,
});
}
resolve();
+44 -8
View File
@@ -220,7 +220,7 @@ async function buildDedicatedTransferConnectConfig(
token,
username,
);
} else if (authType !== "none") {
} else if (authType !== "none" && authType !== "tailscale") {
throw new Error(`Unsupported auth type for transfer: ${authType}`);
}
@@ -725,6 +725,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedIp = ip;
let resolvedPort = port;
let resolvedUsername = username;
let resolvedJumpHosts = jumpHosts;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
@@ -742,6 +743,20 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
resolvedHost.jumpHosts.length > 0
) {
resolvedJumpHosts = resolvedHost.jumpHosts;
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
@@ -775,6 +790,20 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
resolvedHost.jumpHosts.length > 0
) {
resolvedJumpHosts = resolvedHost.jumpHosts;
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
@@ -984,7 +1013,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
connectionLogs,
});
}
} else if (resolvedCredentials.authType === "none") {
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
connectionLogs.push(
createConnectionLog(
"info",
@@ -1234,7 +1266,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
if (
resolvedCredentials.authType === "none" &&
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
@@ -1392,14 +1425,16 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
} else {
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
resolvedCredentials.authType !== "none" &&
resolvedCredentials.authType !== "tailscale";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (
resolvedCredentials.authType === "none" &&
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
@@ -1490,7 +1525,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
: null;
const hasJumpHosts = jumpHosts && jumpHosts.length > 0 && userId;
const hasJumpHosts =
resolvedJumpHosts && resolvedJumpHosts.length > 0 && userId;
if (hasJumpHosts) {
try {
@@ -1507,11 +1543,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
createConnectionLog(
"info",
"jump",
`Connecting via ${jumpHosts.length} jump host(s)`,
`Connecting via ${resolvedJumpHosts.length} jump host(s)`,
),
);
const jumpClient = await createJumpHostChain(
jumpHosts,
resolvedJumpHosts,
userId,
proxyConfig,
);
@@ -3,7 +3,7 @@ import {
supportsMetrics,
isTcpPingEnabled,
createConnectionLog,
} from "./server-stats-helpers.js";
} from "./host-metrics-helpers.js";
describe("supportsMetrics", () => {
it("supports plain ssh hosts", () => {
@@ -0,0 +1,245 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../types/index.js";
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { statsLogger } from "../utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../types/host-metrics.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: "read" | "execute",
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const db = getDb();
const row = db.$client
.prepare(
"SELECT layout FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
)
.get(userId, hostId) as { layout: string } | undefined;
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "read"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const db = getDb();
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
const existing = db.$client
.prepare(
"SELECT id FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
)
.get(userId, hostId) as { id: number } | undefined;
if (existing) {
db.$client
.prepare(
"UPDATE host_metrics_preferences SET layout = ?, updated_at = ? WHERE id = ?",
)
.run(layoutJson, now, existing.id);
} else {
db.$client
.prepare(
"INSERT INTO host_metrics_preferences (user_id, host_id, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
)
.run(userId, hostId, layoutJson, now, now);
}
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
db.$client
.prepare(
"UPDATE ssh_data SET stats_config = ? WHERE id = ? AND user_id = ?",
)
.run(JSON.stringify(merged), hostId, userId);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
DatabaseSaveTrigger.triggerSave("host_metrics_preferences_updated");
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
isMetricCardId,
isManagerCardId,
METRIC_CARD_IDS,
} from "../../types/host-metrics.js";
describe("deriveEnabledWidgets", () => {
it("returns only metric ids, in canonical order, deduped", () => {
const slots = [
{ id: "firewall" },
{ id: "cpu" },
{ id: "cpu" },
{ id: "memory" },
];
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
});
it("excludes manager card ids (mobile contract: never leak managers)", () => {
const slots = [
{ id: "cpu" },
{ id: "service_manager" },
{ id: "log_viewer" },
{ id: "disk" },
];
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(["cpu", "disk"]);
for (const id of out) expect(isMetricCardId(id)).toBe(true);
});
it("output is always a subset of the 10 known WidgetTypes", () => {
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
]);
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS);
expect(out.length).toBe(10);
});
it("empty slots -> empty widgets", () => {
expect(deriveEnabledWidgets([])).toEqual([]);
});
});
describe("defaultLayoutFromWidgets", () => {
it("builds slots in canonical order with dense ordering", () => {
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
expect(layout.columns).toBe(3);
});
it("ignores unknown widget ids", () => {
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
});
it("assigns valid colSpans (1..3)", () => {
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
for (const s of layout.slots) {
expect([1, 2, 3]).toContain(s.colSpan);
}
});
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
expect(deriveEnabledWidgets(layout.slots)).toEqual([
"cpu",
"ports",
"firewall",
]);
});
});
describe("card id guards", () => {
it("classifies metric vs manager ids", () => {
expect(isMetricCardId("cpu")).toBe(true);
expect(isMetricCardId("service_manager")).toBe(false);
expect(isManagerCardId("service_manager")).toBe(true);
expect(isManagerCardId("cpu")).toBe(false);
});
});
@@ -2,24 +2,24 @@ import type { Express, RequestHandler } from "express";
import { getDb } from "../database/db/index.js";
import { statsLogger } from "../utils/logger.js";
type ServerStatsSettingsConfig = {
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
metricsInterval: number;
};
type ServerStatsSettingsRoutesDeps = {
type HostMetricsSettingsRoutesDeps = {
requireAdmin: RequestHandler;
defaultStatsConfig: ServerStatsSettingsConfig;
defaultStatsConfig: HostMetricsSettingsConfig;
refreshAllPolling: () => Promise<void>;
};
export function registerServerStatsSettingsRoutes(
export function registerHostMetricsSettingsRoutes(
app: Express,
{
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling,
}: ServerStatsSettingsRoutesDeps,
}: HostMetricsSettingsRoutesDeps,
): void {
/**
* @openapi
@@ -27,7 +27,7 @@ export function registerServerStatsSettingsRoutes(
* get:
* summary: Get global monitoring defaults
* tags:
* - Stats
* - Host Metrics
* responses:
* 200:
* description: Global monitoring settings.
@@ -88,7 +88,7 @@ export function registerServerStatsSettingsRoutes(
* post:
* summary: Update global monitoring defaults
* tags:
* - Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -7,7 +7,7 @@ type ViewerStatsConfig = {
metricsEnabled: boolean;
};
type ServerStatsViewerRoutesDeps<
type HostMetricsViewerRoutesDeps<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
> = {
@@ -23,7 +23,7 @@ type ServerStatsViewerRoutesDeps<
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
};
export function registerServerStatsViewerRoutes<
export function registerHostMetricsViewerRoutes<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
>(
@@ -35,7 +35,7 @@ export function registerServerStatsViewerRoutes<
updateHeartbeat,
registerViewer,
unregisterViewer,
}: ServerStatsViewerRoutesDeps<THost, TStatsConfig>,
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
): void {
/**
* @openapi
@@ -44,7 +44,7 @@ export function registerServerStatsViewerRoutes<
* summary: Update viewer heartbeat
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -105,7 +105,7 @@ export function registerServerStatsViewerRoutes<
* summary: Register metrics viewer
* description: Registers a new viewer session for a host to track who is viewing metrics.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -233,7 +233,7 @@ export function registerServerStatsViewerRoutes<
* summary: Unregister metrics viewer
* description: Unregisters a viewer session when they stop viewing metrics for a host.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -4,6 +4,7 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { pickResolvedUsername } from "./credential-username.js";
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
@@ -29,14 +30,18 @@ import {
} from "../utils/socks5-helper.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
import { registerServerStatsSettingsRoutes } from "./server-stats-settings-routes.js";
import { registerServerStatsViewerRoutes } from "./server-stats-viewer-routes.js";
import { createJumpHostChain } from "./server-stats-jump-hosts.js";
import { registerHostMetricsSettingsRoutes } from "./host-metrics-settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./host-metrics-viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences-routes.js";
import { registerManagerRoutes } from "./managers/index.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
import {
createConnectionLog,
isTcpPingEnabled,
supportsMetrics,
} from "./server-stats-helpers.js";
} from "./host-metrics-helpers.js";
import {
cleanupMetricsSession,
getSessionKey,
@@ -44,13 +49,13 @@ import {
pendingTOTPSessions,
scheduleMetricsSessionCleanup,
type MetricsViewer,
} from "./server-stats-sessions.js";
} from "./host-metrics-sessions.js";
import {
authFailureTracker,
metricsCache,
pollingBackoff,
requestQueue,
} from "./server-stats-state.js";
} from "./host-metrics-state.js";
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -71,6 +76,7 @@ interface SSHHostWithCredentials {
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
credentialId?: number;
enableTerminal: boolean;
enableTunnel: boolean;
@@ -762,6 +768,19 @@ async function resolveHostCredentials(
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
statsConfig: host.statsConfig || undefined,
sudoPassword: (() => {
const top = host.sudoPassword as string | null | undefined;
if (top) return top;
try {
const tc =
typeof host.terminalConfig === "string"
? JSON.parse(host.terminalConfig as string)
: host.terminalConfig;
return (tc?.sudoPassword as string) || undefined;
} catch {
return undefined;
}
})(),
createdAt: host.createdAt,
updatedAt: host.updatedAt,
userId: host.userId,
@@ -793,9 +812,11 @@ async function resolveHostCredentials(
baseHost.credentialId = host.credentialId;
baseHost.authType = sharedCred.authType;
if (!host.overrideCredentialUsername) {
baseHost.username = sharedCred.username;
}
baseHost.username = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
if (sharedCred.password) {
baseHost.password = sharedCred.password;
@@ -832,9 +853,11 @@ async function resolveHostCredentials(
? "key"
: "none");
if (!host.overrideCredentialUsername) {
baseHost.username = credential.username;
}
baseHost.username = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (credential.password) {
baseHost.password = credential.password;
@@ -1005,7 +1028,7 @@ async function buildSshConfig(
cause: keyError,
});
}
} else if (host.authType === "none") {
} else if (host.authType === "none" || host.authType === "tailscale") {
// no credentials needed
} else if (host.authType === "opkssh") {
// cert auth setup happens in createSshFactory (needs client instance)
@@ -1478,7 +1501,7 @@ function tcpPing(
* summary: Get all host statuses
* description: Retrieves the status of all hosts for the authenticated user.
* tags:
* - Server Stats
* - Host Metrics
* responses:
* 200:
* description: A map of host IDs to their status entries.
@@ -1514,7 +1537,7 @@ app.get("/status", async (req, res) => {
* summary: Get host status by ID
* description: Retrieves the status of a specific host by its ID.
* tags:
* - Server Stats
* - Host Metrics
* parameters:
* - in: path
* name: id
@@ -1560,7 +1583,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
* summary: Clear all SSH connections
* description: Clears all SSH connections from the connection pool.
* tags:
* - Server Stats
* - Host Metrics
* responses:
* 200:
* description: All SSH connections cleared.
@@ -1588,7 +1611,7 @@ app.post("/clear-connections", async (req, res) => {
* summary: Refresh polling
* description: Clears all SSH connections and refreshes host polling.
* tags:
* - Server Stats
* - Host Metrics
* responses:
* 200:
* description: Polling refreshed.
@@ -1618,7 +1641,7 @@ app.post("/refresh", async (req, res) => {
* summary: Start polling for updated host
* description: Starts polling for a specific host after it has been updated.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -1682,7 +1705,7 @@ app.post("/host-updated", async (req, res) => {
* summary: Stop polling for deleted host
* description: Stops polling for a specific host after it has been deleted.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -1737,7 +1760,7 @@ app.post("/host-deleted", async (req, res) => {
* summary: Get host metrics
* description: Retrieves current metrics for a specific host including CPU, memory, disk, network, processes, and system information.
* tags:
* - Server Stats
* - Host Metrics
* parameters:
* - in: path
* name: id
@@ -1796,7 +1819,7 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
* summary: Start metrics collection
* description: Establishes an SSH connection and starts collecting metrics for a specific host.
* tags:
* - Server Stats
* - Host Metrics
* parameters:
* - in: path
* name: id
@@ -2232,7 +2255,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
* summary: Stop metrics collection
* description: Stops metrics collection for a specific host and cleans up the SSH session.
* tags:
* - Server Stats
* - Host Metrics
* parameters:
* - in: path
* name: id
@@ -2305,7 +2328,7 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => {
* summary: Complete TOTP verification for metrics
* description: Verifies the TOTP code and completes the metrics SSH connection.
* tags:
* - Server Stats
* - Host Metrics
* requestBody:
* required: true
* content:
@@ -2470,7 +2493,7 @@ app.post("/metrics/connect-totp", async (req, res) => {
}
});
registerServerStatsViewerRoutes(app, {
registerHostMetricsViewerRoutes(app, {
fetchHostById,
supportsMetrics: (host: SSHHostWithCredentials) => supportsMetrics(host),
parseStatsConfig: (statsConfig: SSHHostWithCredentials["statsConfig"]) =>
@@ -2483,12 +2506,52 @@ registerServerStatsViewerRoutes(app, {
pollingManager.unregisterViewer(hostId, viewerSessionId),
});
registerServerStatsSettingsRoutes(app, {
registerHostMetricsSettingsRoutes(app, {
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling: () => pollingManager.refreshAllPolling(),
});
registerHostMetricsPreferencesRoutes(app, {
validateHostId,
fetchHostById: async (hostId, userId) =>
(await fetchHostById(hostId, userId)) ?? null,
parseStatsConfig: (statsConfig) =>
pollingManager.parseStatsConfig(
statsConfig as string | StatsConfig | undefined,
),
canAccessHost: async (userId, hostId, level) =>
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
});
registerManagerRoutes(app, {
validateHostId,
runOnHost: async (hostId, userId, level, fn) => {
const access = await permissionManager.canAccessHost(userId, hostId, level);
if (!access.hasAccess) {
throw new AccessDeniedError();
}
const host = await fetchHostById(hostId, userId);
if (!host) {
throw new AccessDeniedError("Host not found");
}
// sudoPassword is encrypted with the owner's key; when the viewer is a
// different user, re-fetch under the owner's key to decrypt it correctly.
let sudoPassword = host.sudoPassword;
if (host.userId && host.userId !== userId) {
const ownerHost = await fetchHostById(hostId, host.userId);
if (ownerHost) sudoPassword = ownerHost.sudoPassword;
}
const managerHost: ManagerHost = {
id: host.id,
userId: host.userId,
sudoPassword,
enableDocker: !!(host as { enableDocker?: boolean }).enableDocker,
};
return withSshConnection(host, (client) => fn(client, managerHost));
},
});
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();
+16 -9
View File
@@ -3,6 +3,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { logger } from "../utils/logger.js";
import { pickResolvedUsername } from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
const sshLogger = logger;
@@ -109,9 +110,11 @@ export async function resolveHostById(
host.key = cred.key;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
if (!host.overrideCredentialUsername) {
host.username = cred.username;
}
host.username = pickResolvedUsername(
host.username,
cred.username,
host.overrideCredentialUsername,
);
host.authType = cred.key
? "key"
: cred.password
@@ -137,9 +140,11 @@ export async function resolveHostById(
host.key = sharedCred.key;
host.keyPassword = sharedCred.keyPassword;
host.keyType = sharedCred.keyType;
if (!host.overrideCredentialUsername) {
host.username = sharedCred.username;
}
host.username = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
host.authType = sharedCred.key
? "key"
: sharedCred.password
@@ -182,9 +187,11 @@ export async function resolveHostById(
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).certPublicKey =
cred.certPublicKey || null;
if (!host.overrideCredentialUsername) {
host.username = cred.username;
}
host.username = pickResolvedUsername(
host.username,
cred.username,
host.overrideCredentialUsername,
);
host.authType = host.key ? "key" : host.password ? "password" : "none";
}
} catch (e) {
+127
View File
@@ -0,0 +1,127 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
raw: string;
enabled: boolean;
schedule: string;
command: string;
}
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
export function parseCrontab(output: string): CronEntry[] {
const entries: CronEntry[] = [];
for (const raw of output.split("\n")) {
const line = raw.replace(/\s+$/, "");
if (!line.trim()) continue;
// A commented-out job: "# <schedule> <command>" (our toggle convention).
const disabled = line.match(
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
);
if (disabled) {
const { schedule, command } = splitScheduleCommand(disabled[1]);
if (command) {
entries.push({ raw: line, enabled: false, schedule, command });
continue;
}
}
// Skip pure comments / env assignments.
if (line.trimStart().startsWith("#")) continue;
if (/^\s*[A-Z_]+=/.test(line)) continue;
const { schedule, command } = splitScheduleCommand(line.trim());
if (!command) continue;
entries.push({ raw: line, enabled: true, schedule, command });
}
return entries;
}
function splitScheduleCommand(line: string): {
schedule: string;
command: string;
} {
if (line.startsWith("@")) {
const [sched, ...rest] = line.split(/\s+/);
return { schedule: sched, command: rest.join(" ") };
}
const parts = line.split(/\s+/);
if (parts.length < 6) return { schedule: "", command: "" };
return {
schedule: parts.slice(0, 5).join(" "),
command: parts.slice(5).join(" "),
};
}
const CRON_SCHEDULE_RE =
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
export function isValidCronSchedule(schedule: string): boolean {
return CRON_SCHEDULE_RE.test(schedule.trim());
}
/** Serialize entries into a full crontab body (toggled entries are commented). */
export function serializeCrontab(entries: CronEntry[]): string {
const lines = entries.map((e) => {
const body = `${e.schedule} ${e.command}`.trim();
return e.enabled ? body : `# ${body}`;
});
return lines.join("\n") + (lines.length ? "\n" : "");
}
/** Build a command that atomically replaces the user crontab from the given body. */
export function buildApplyCrontabCommand(body: string): string {
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "read", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
);
app.post(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
"execute",
"cron_replace",
async (client, _host, req) => {
const { entries } = req.body as { entries?: CronEntry[] };
if (!Array.isArray(entries)) {
throw new ManagerInputError("entries must be an array");
}
for (const e of entries) {
if (typeof e?.command !== "string" || !e.command.trim()) {
throw new ManagerInputError("Each entry needs a command");
}
if (e.command.includes("\n")) {
throw new ManagerInputError("Commands cannot contain newlines");
}
if (!isValidCronSchedule(String(e.schedule))) {
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
}
}
const body = serializeCrontab(entries);
const { stdout, stderr, code } = await execCommand(
client,
buildApplyCrontabCommand(body),
15000,
);
return { success: code === 0, output: stdout || stderr };
},
),
);
}
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock("../widgets/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { execElevated, ElevationError } from "./exec-elevated.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, stderr = "", code: number | null = 0) {
return { stdout, stderr, code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("execElevated (no force)", () => {
it("returns the direct result when the command succeeds unprivileged", async () => {
execCommand.mockResolvedValueOnce(result("hello", "", 0));
const r = await execElevated(fakeClient, "echo hello", "pw");
expect(r.usedSudo).toBe(false);
expect(r.stdout).toBe("hello");
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("does NOT escalate when stdout merely contains scary words", async () => {
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
// not should be surfaced as-is, never retried under sudo.
execCommand.mockResolvedValueOnce(
result("log line: permission denied for user foo", "", 1),
);
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
expect(r.usedSudo).toBe(false);
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("escalates when stderr indicates a permission problem", async () => {
execCommand
.mockResolvedValueOnce(result("", "Permission denied", 1))
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
expect(r.usedSudo).toBe(true);
expect(r.stdout).toBe("elevated output");
expect(execCommand).toHaveBeenCalledTimes(2);
});
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
await expect(
execElevated(fakeClient, "cat /etc/shadow", undefined),
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
});
});
describe("execElevated (forced)", () => {
it("strips the success marker from stdout", async () => {
execCommand.mockResolvedValueOnce(
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
);
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
expect(r.stdout).toBe("the real output\n");
expect(r.usedSudo).toBe(true);
});
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
execCommand.mockResolvedValueOnce(
result(
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
"",
0,
),
);
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
forceSudo: true,
});
expect(r.usedSudo).toBe(true);
expect(r.stdout).toContain("incorrect password");
});
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
execCommand.mockResolvedValueOnce(
result("", "sudo: 1 incorrect password attempt", 1),
);
await expect(
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
).rejects.toMatchObject({ code: "SUDO_FAILED" });
});
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
execCommand.mockResolvedValueOnce(
result("", "deploy is not in the sudoers file.", 1),
);
await expect(
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
).rejects.toMatchObject({ code: "NOT_SUDOER" });
});
it("throws SUDO_REQUIRED when forced without a password", async () => {
await expect(
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
).rejects.toBeInstanceOf(ElevationError);
expect(execCommand).not.toHaveBeenCalled();
});
});
+169
View File
@@ -0,0 +1,169 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. We only match these against sudo's stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started the
* inner shell. Its presence on stdout is the authoritative "elevation worked"
* signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
* genuine auth failure from command output that merely contains scary words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a pooled client, elevating with the host's stored sudo
* password only when needed (or when forced). Throws a typed `ElevationError`
* when elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure but
// keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
+129
View File
@@ -0,0 +1,129 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
protocol: IpProtocol;
port: number;
target: FirewallTarget;
}
/**
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
* specific dport with an explicit target, and never the chain policy, so an
* existing ESTABLISHED/SSH rule is left intact.
*/
export function buildIptablesRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
const flag = op === "add" ? "-A" : "-D";
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
}
export function buildNftRuleCommand(
op: "add" | "delete",
spec: FirewallRuleSpec,
): string {
// nftables uses the inet filter table's input chain by convention.
const verb = op === "add" ? "add" : "delete";
const action =
spec.target.toLowerCase() === "reject"
? "reject"
: spec.target.toLowerCase();
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
app.post(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_rule",
async (client, host, req) => {
const { op, protocol, port, target } = req.body as {
op?: "add" | "delete";
protocol?: string;
port?: number;
target?: string;
};
if (op !== "add" && op !== "delete") {
throw new ManagerInputError("Invalid op");
}
if (!isValidIpProtocol(protocol))
throw new ManagerInputError("Invalid protocol");
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
if (!isValidFirewallTarget(target))
throw new ManagerInputError("Invalid target");
const spec: FirewallRuleSpec = {
protocol,
port: Number(port),
target,
};
const fw = await collectFirewallMetrics(client);
const cmd =
fw.type === "nftables"
? buildNftRuleCommand(op, spec)
: buildIptablesRuleCommand(op, spec);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
backend: fw.type,
};
},
),
);
app.post(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
"execute",
"firewall_persist",
async (client, host) => {
const platform = await detectPlatform(client);
// Best-effort persistence across common tools.
const cmd =
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
pkg: platform.pkg,
};
},
),
);
}
+257
View File
@@ -0,0 +1,257 @@
import type { Express } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { execCommand } from "../widgets/common-utils.js";
import { getDb, DatabaseSaveTrigger } from "../../database/db/index.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isValidPort } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface HealthCheck {
id: string;
name: string;
type: "tcp" | "http";
target: string;
port?: number;
path?: string;
}
export interface HealthResult {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
export function isValidHealthCheck(c: unknown): c is HealthCheck {
if (!c || typeof c !== "object") return false;
const o = c as Record<string, unknown>;
if (typeof o.id !== "string" || !o.id) return false;
if (typeof o.name !== "string") return false;
if (o.type !== "tcp" && o.type !== "http") return false;
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
if (o.type === "tcp" && !isValidPort(o.port)) return false;
if (
o.path !== undefined &&
(typeof o.path !== "string" || !PATH_RE.test(o.path))
)
return false;
return true;
}
/** Build a command that runs the check from the host and prints "ok latency". */
export function buildHealthCheckCommand(check: HealthCheck): string {
if (check.type === "tcp") {
const host = shellSingleQuote(check.target);
const port = check.port;
// Prefer bash /dev/tcp; time it with date in ms.
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
}
// http
const scheme = check.target.includes("://") ? "" : "http://";
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
}
export function parseHealthResult(
check: HealthCheck,
output: string,
): HealthResult {
const line = output.trim().split("\n").pop() ?? "";
if (check.type === "tcp") {
const [status, ms] = line.split(/\s+/);
return {
checkId: check.id,
ok: status === "ok",
latencyMs: Number(ms) || null,
detail: status === "ok" ? "open" : "closed/timeout",
};
}
const m = line.match(/^(\d{3})\s+([\d.]+)/);
if (!m)
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
const code = Number(m[1]);
return {
checkId: check.id,
ok: code >= 200 && code < 400,
latencyMs: Math.round(Number(m[2]) * 1000),
detail: `HTTP ${code}`,
};
}
async function runChecks(
client: Client,
checks: HealthCheck[],
): Promise<HealthResult[]> {
return Promise.all(
checks.map(async (check) => {
try {
const { stdout } = await execCommand(
client,
buildHealthCheckCommand(check),
8000,
);
return parseHealthResult(check, stdout);
} catch {
return {
checkId: check.id,
ok: false,
latencyMs: null,
detail: "error",
};
}
}),
);
}
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): void {
const db = getDb();
const now = new Date().toISOString();
const insert = db.$client.prepare(
"INSERT INTO host_health_history (user_id, host_id, check_id, ts, ok, latency_ms, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
);
for (const r of results) {
insert.run(
userId,
hostId,
r.checkId,
now,
r.ok ? 1 : 0,
r.latencyMs,
r.detail,
);
}
// Prune to the most recent HISTORY_KEEP rows per (host, check).
db.$client
.prepare(
`DELETE FROM host_health_history
WHERE id IN (
SELECT id FROM host_health_history
WHERE user_id = ? AND host_id = ?
AND id NOT IN (
SELECT id FROM host_health_history
WHERE user_id = ? AND host_id = ?
ORDER BY ts DESC LIMIT ?
)
)`,
)
.run(userId, hostId, userId, hostId, HISTORY_KEEP);
}
function loadChecks(userId: string, hostId: number): HealthCheck[] {
const row = getDb()
.$client.prepare(
"SELECT checks FROM host_health_checks WHERE user_id = ? AND host_id = ?",
)
.get(userId, hostId) as { checks: string } | undefined;
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
} catch {
return [];
}
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) recordHistory(userId, host.id, results);
const history = getDb()
.$client.prepare(
"SELECT check_id as checkId, ts, ok, latency_ms as latencyMs, detail FROM host_health_history WHERE user_id = ? AND host_id = ? ORDER BY ts DESC LIMIT 200",
)
.all(userId, host.id);
return { checks, results, history };
},
),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
};
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
throw new ManagerInputError("Invalid checks");
}
const interval =
typeof intervalSeconds === "number" &&
intervalSeconds >= 30 &&
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const db = getDb();
const now = new Date().toISOString();
const existing = db.$client
.prepare(
"SELECT id FROM host_health_checks WHERE user_id = ? AND host_id = ?",
)
.get(userId, host.id) as { id: number } | undefined;
if (existing) {
db.$client
.prepare(
"UPDATE host_health_checks SET checks = ?, interval_seconds = ?, updated_at = ? WHERE id = ?",
)
.run(JSON.stringify(checks), interval, now, existing.id);
} else {
db.$client
.prepare(
"INSERT INTO host_health_checks (user_id, host_id, checks, interval_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(userId, host.id, JSON.stringify(checks), interval, now, now);
}
DatabaseSaveTrigger.triggerSave("host_health_checks_updated");
return { success: true };
},
),
);
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"read",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) recordHistory(userId, host.id, results);
return { results };
},
),
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Express } from "express";
import { detectPlatform } from "./platform.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
import { registerProcessRoutes } from "./processes.js";
import { registerSimpleReadRoutes } from "./simple-reads.js";
import { registerCronRoutes } from "./cron.js";
import { registerPackageRoutes } from "./packages.js";
import { registerSslRoutes } from "./ssl.js";
import { registerFirewallRoutes } from "./firewall.js";
import { registerUserRoutes } from "./users.js";
import { registerHealthRoutes } from "./health.js";
import { registerLogRoutes } from "./logs.js";
/**
* Registers every Host Metrics manager route under the `/host-metrics/managers`
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
/**
* @openapi
* /host-metrics/platform/{id}:
* get:
* summary: Detect available management tooling on a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Platform capabilities (systemd, package manager, certbot, docker).
*/
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "read", "platform_detect", (client) =>
detectPlatform(client),
),
);
registerServiceRoutes(app, deps);
registerProcessRoutes(app, deps);
registerSimpleReadRoutes(app, deps);
registerCronRoutes(app, deps);
registerPackageRoutes(app, deps);
registerSslRoutes(app, deps);
registerFirewallRoutes(app, deps);
registerUserRoutes(app, deps);
registerHealthRoutes(app, deps);
registerLogRoutes(app, deps);
}
+92
View File
@@ -0,0 +1,92 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "./exec-elevated.js";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
export const LOG_PATH_ALLOWLIST = ["/var/log"];
const COMMON_LOGS = [
"/var/log/syslog",
"/var/log/messages",
"/var/log/auth.log",
"/var/log/secure",
"/var/log/kern.log",
"/var/log/dpkg.log",
"/var/log/nginx/access.log",
"/var/log/nginx/error.log",
];
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
export function clampLines(n: unknown): number {
const v = typeof n === "string" ? Number(n) : n;
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
return Math.min(2000, Math.max(1, Math.round(v)));
}
export function buildTailCommand(path: string, lines: number): string {
// Keep stderr intact so execElevated can detect a permission error and
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
// empty log with no chance to retry under sudo.
return `tail -n ${lines} ${shellSingleQuote(path)}`;
}
export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "read", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((name) => `/var/log/${name}`);
return { common: COMMON_LOGS, files: found };
}),
);
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"read",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
);
}
+416
View File
@@ -0,0 +1,416 @@
import { describe, it, expect } from "vitest";
import { buildSudoCommand, shellSingleQuote } from "./exec-elevated.js";
import { parsePlatformProbe } from "./platform.js";
import {
isValidSystemdUnit,
isValidPid,
isValidPort,
isValidPackageName,
isValidUsername,
isValidDomain,
isValidDnsProvider,
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "./validation.js";
import { parseServiceList, buildServiceActionCommand } from "./services.js";
import { parseProcessList, buildKillCommand } from "./processes.js";
import { parseDfMounts, parseTopMemory } from "./simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "./cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "./packages.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "./ssl.js";
import { buildIptablesRuleCommand, buildNftRuleCommand } from "./firewall.js";
import { parsePasswd, parseSudoers } from "./users.js";
import { buildHealthCheckCommand, parseHealthResult } from "./health.js";
import { buildTailCommand, clampLines } from "./logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
expect(shellSingleQuote("abc")).toBe("'abc'");
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
});
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
);
});
it("does not merge stderr into stdout (no 2>&1)", () => {
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
});
it("escapes a password containing a quote", () => {
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
});
});
describe("platform probe parsing", () => {
it("parses capabilities and prefers dnf over yum", () => {
const out = [
"systemd=1",
"apt=0",
"dnf=1",
"yum=1",
"pacman=0",
"certbot=1",
"acmesh=0",
"docker=1",
"os=Fedora Linux 40",
].join("\n");
const p = parsePlatformProbe(out);
expect(p.hasSystemd).toBe(true);
expect(p.pkg).toBe("dnf");
expect(p.hasCertbot).toBe(true);
expect(p.hasAcmeSh).toBe(false);
expect(p.hasDocker).toBe(true);
expect(p.osPrettyName).toBe("Fedora Linux 40");
});
it("picks apt when present", () => {
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
});
});
describe("validation (injection defense)", () => {
it("systemd units", () => {
expect(isValidSystemdUnit("nginx.service")).toBe(true);
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
expect(isValidSystemdUnit("nginx")).toBe(false);
});
it("pids", () => {
expect(isValidPid(123)).toBe(true);
expect(isValidPid("123")).toBe(true);
expect(isValidPid(0)).toBe(false);
expect(isValidPid("1; reboot")).toBe(false);
expect(isValidPid(-5)).toBe(false);
});
it("ports", () => {
expect(isValidPort(443)).toBe(true);
expect(isValidPort(0)).toBe(false);
expect(isValidPort(70000)).toBe(false);
});
it("package names", () => {
expect(isValidPackageName("nginx")).toBe(true);
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
expect(isValidPackageName("nginx && curl evil")).toBe(false);
expect(isValidPackageName("-rf")).toBe(false);
});
it("usernames and domains", () => {
expect(isValidUsername("deploy")).toBe(true);
expect(isValidUsername("root; rm")).toBe(false);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("*.example.com")).toBe(true);
expect(isValidDomain("ex ample.com")).toBe(false);
expect(isValidDomain("a;b.com")).toBe(false);
});
it("dns providers, signals, service actions", () => {
expect(isValidDnsProvider("cloudflare")).toBe(true);
expect(isValidDnsProvider("cf; rm")).toBe(false);
expect(isValidSignal("KILL")).toBe(true);
expect(isValidSignal("BOOM")).toBe(false);
expect(isValidServiceAction("restart")).toBe(true);
expect(isValidServiceAction("destroy")).toBe(false);
});
it("path allowlist rejects traversal and out-of-allowlist", () => {
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
false,
);
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
});
});
describe("services", () => {
it("parses list-units --plain", () => {
const out =
"nginx.service loaded active running A high performance web server\n" +
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
"cron.service loaded inactive dead Regular background program";
const rows = parseServiceList(out);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
expect(rows[2].active).toBe("inactive");
});
it("builds action command", () => {
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
"systemctl restart nginx.service",
);
});
});
describe("processes", () => {
it("parses ps output", () => {
const out =
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
const rows = parseProcessList(out);
expect(rows[0]).toMatchObject({
pid: 1234,
user: "root",
cpu: 12.5,
command: "/usr/bin/node",
});
expect(rows[1].pid).toBe(5678);
});
it("builds kill command", () => {
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
});
});
describe("simple reads", () => {
it("parses df -Pk and skips tmpfs", () => {
const out =
"/dev/sda1 100000 40000 60000 40% /\n" +
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"/dev/sdb1 200000 100000 100000 50% /data";
const mounts = parseDfMounts(out);
expect(mounts).toHaveLength(2);
expect(mounts[0].mount).toBe("/");
expect(mounts[1].usePct).toBe(50);
});
it("parses top by memory", () => {
const rows = parseTopMemory("1234 root 5.5 50000 node");
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
});
});
describe("cron", () => {
it("parses enabled and toggled entries", () => {
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
const entries = parseCrontab(out);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
expect(entries[1].enabled).toBe(false);
});
it("validates schedules", () => {
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
expect(isValidCronSchedule("@daily")).toBe(true);
expect(isValidCronSchedule("not a schedule")).toBe(false);
});
it("serializes (commenting disabled entries) and round-trips", () => {
const body = serializeCrontab([
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
]);
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
const reparsed = parseCrontab(body);
expect(reparsed[0].enabled).toBe(true);
expect(reparsed[1].enabled).toBe(false);
});
it("builds apply command piping into crontab -", () => {
expect(buildApplyCrontabCommand("x\n")).toBe(
`printf '%s' 'x\n' | crontab -`,
);
});
});
describe("packages", () => {
it("builds per-distro commands", () => {
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
"apt-get -y install nginx",
);
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
"pacman -Syu --noconfirm",
);
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
});
it("lists per distro", () => {
expect(buildListUpgradableCommand("apt")).toContain(
"apt list --upgradable",
);
expect(buildListUpgradableCommand(null)).toBeNull();
});
it("parses apt upgradable", () => {
const out =
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
const pkgs = parseUpgradable("apt", out);
expect(pkgs[0]).toMatchObject({
name: "nginx",
newVersion: "1.18.0-2",
currentVersion: "1.18.0-1",
});
});
it("parses pacman upgradable", () => {
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
});
});
describe("ssl (dual client)", () => {
it("builds certbot issue for http + dns", () => {
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "http-standalone",
}),
).toContain(
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
);
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "dns",
dnsProvider: "cloudflare",
}),
).toContain("--dns-cloudflare");
});
it("builds acme.sh issue", () => {
const cmd = buildIssueCommand({
client: "acme.sh",
domains: ["a.com", "b.com"],
challenge: "dns",
dnsProvider: "cf",
});
expect(cmd).toContain("--issue --dns dns_cf");
expect(cmd).toContain("-d 'a.com'");
expect(cmd).toContain("-d 'b.com'");
});
it("builds renew per client", () => {
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
});
it("builds revoke per client", () => {
expect(buildRevokeCommand("certbot", "example.com")).toBe(
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
);
const acme = buildRevokeCommand("acme.sh", "example.com");
expect(acme).toContain("--revoke -d 'example.com'");
expect(acme).toContain("--remove -d 'example.com'");
});
it("validates certificate names (rejects shell metachars)", () => {
expect(isValidCertName("example.com")).toBe(true);
expect(isValidCertName("example.com-0001")).toBe(true);
expect(isValidCertName("*.example.com")).toBe(true);
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
expect(isValidCertName("")).toBe(false);
expect(isValidCertName(undefined)).toBe(false);
});
it("parses certbot certificates", () => {
const out = [
"Found the following certs:",
" Certificate Name: example.com",
" Domains: example.com www.example.com",
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
].join("\n");
const certs = parseCertbotCertificates(out);
expect(certs[0]).toMatchObject({
name: "example.com",
client: "certbot",
});
expect(certs[0].domains).toContain("www.example.com");
});
});
describe("firewall", () => {
it("builds iptables add/delete on INPUT only", () => {
expect(
buildIptablesRuleCommand("add", {
protocol: "tcp",
port: 443,
target: "ACCEPT",
}),
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
expect(
buildIptablesRuleCommand("delete", {
protocol: "udp",
port: 53,
target: "DROP",
}),
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
});
it("builds nft rules", () => {
expect(
buildNftRuleCommand("add", {
protocol: "tcp",
port: 22,
target: "ACCEPT",
}),
).toContain("add rule inet filter input tcp dport 22 accept");
});
});
describe("users", () => {
it("parses passwd for human users only", () => {
const out =
"root:x:0:0:root:/root:/bin/bash\n" +
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
const users = parsePasswd(out);
expect(users).toHaveLength(1);
expect(users[0].name).toBe("deploy");
});
it("parses sudoers membership", () => {
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
});
});
describe("health checks", () => {
it("builds tcp and http commands", () => {
const tcp = buildHealthCheckCommand({
id: "1",
name: "ssh",
type: "tcp",
target: "localhost",
port: 22,
});
expect(tcp).toContain("/dev/tcp/");
const http = buildHealthCheckCommand({
id: "2",
name: "web",
type: "http",
target: "example.com",
path: "/health",
});
expect(http).toContain("curl -s -o /dev/null");
expect(http).toContain("http://example.com/health");
});
it("parses tcp and http results", () => {
const tcp = parseHealthResult(
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
"ok 12",
);
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
const http = parseHealthResult(
{ id: "2", name: "web", type: "http", target: "h" },
"200 0.045",
);
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
const bad = parseHealthResult(
{ id: "3", name: "web", type: "http", target: "h" },
"500 0.01",
);
expect(bad.ok).toBe(false);
});
});
describe("logs", () => {
it("clamps line counts", () => {
expect(clampLines(50)).toBe(50);
expect(clampLines(99999)).toBe(2000);
expect(clampLines("abc")).toBe(200);
expect(clampLines(0)).toBe(1);
});
it("builds a quoted tail command without suppressing stderr", () => {
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
"tail -n 100 '/var/log/syslog'",
);
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "./validation.js";
import { detectPlatform, type PackageManager } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "read", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
const { stdout } = await execCommand(client, cmd, 60000);
return {
pkg: platform.pkg,
upgradable: parseUpgradable(platform.pkg, stdout),
};
}),
);
app.post(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"packages_action",
async (client, host, req) => {
const { action, pkg: name } = req.body as {
action?: PackageAction;
pkg?: string;
};
if (
action !== "upgrade-all" &&
action !== "install" &&
action !== "upgrade"
) {
throw new ManagerInputError("Invalid action");
}
if (action !== "upgrade-all" && !isValidPackageName(name)) {
throw new ManagerInputError("Invalid package name");
}
const platform = await detectPlatform(client);
const cmd = buildPackageActionCommand(platform.pkg, action, name);
if (!cmd) throw new ManagerInputError("No supported package manager");
// Package operations can be slow; allow up to 10 minutes.
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 600000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Client } from "ssh2";
import { execCommand } from "../widgets/common-utils.js";
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
"echo docker=$(command -v docker >/dev/null 2>&1 && echo 1 || echo 0)",
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
+130
View File
@@ -0,0 +1,130 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
pid: number;
ppid: number;
user: string;
cpu: number;
mem: number;
rss: number;
stat: string;
command: string;
args: string;
}
const LIST_PROCESSES_CMD =
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
export function parseProcessList(output: string): ProcessRow[] {
const rows: ProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
);
if (!m) continue;
rows.push({
pid: Number(m[1]),
ppid: Number(m[2]),
user: m[3],
cpu: Number(m[4]),
mem: Number(m[5]),
rss: Number(m[6]),
stat: m[7],
command: m[8],
args: m[9],
});
}
return rows;
}
export function buildKillCommand(pid: number, signal: Signal): string {
return `kill -${signal} ${pid}`;
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/processes/{id}:
* get:
* summary: List processes (rich, sortable, filterable client-side)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Process list. }
*/
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "read", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/processes/{id}/signal:
* post:
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* pid: { type: integer }
* signal: { type: string }
* responses:
* 200: { description: Signal result. }
* 400: { description: Invalid pid or signal. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
"execute",
"processes_signal",
async (client, host, req) => {
const { pid, signal } = req.body as {
pid?: number;
signal?: string;
};
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
if (!isValidSignal(signal))
throw new ManagerInputError("Invalid signal");
const cmd = buildKillCommand(Number(pid), signal);
// Try unprivileged first; elevate only if the process isn't owned.
const result = await execElevated(client, cmd, host.sudoPassword);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
usedSudo: result.usedSudo,
};
},
),
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { statsLogger } from "../../utils/logger.js";
import { ElevationError } from "./exec-elevated.js";
import type { ManagerHost, RunOnHost } from "./types.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
super(message);
this.name = "AccessDeniedError";
}
}
export class ManagerInputError extends Error {
constructor(message: string) {
super(message);
this.name = "ManagerInputError";
}
}
/**
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: "read" | "execute",
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
} catch (error) {
if (error instanceof ManagerInputError) {
return res.status(400).json({ error: error.message });
}
if (error instanceof AccessDeniedError) {
return res.status(403).json({ error: error.message });
}
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: error instanceof Error ? error.message : "Operation failed",
});
}
};
}
+135
View File
@@ -0,0 +1,135 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
unit: string;
load: string;
active: string;
sub: string;
description: string;
}
const LIST_SERVICES_CMD =
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
/** Parse `systemctl list-units --plain` output into structured rows. */
export function parseServiceList(output: string): SystemdService[] {
const services: SystemdService[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const [unit, load, active, sub, ...rest] = parts;
if (!unit.endsWith(".service")) continue;
services.push({
unit,
load,
active,
sub,
description: rest.join(" "),
});
}
return services;
}
export function buildServiceActionCommand(
unit: string,
action: ServiceAction,
): string {
return `systemctl ${action} ${unit}`;
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/managers/services/{id}:
* get:
* summary: List systemd services
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: List of services. }
*/
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "read", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
);
/**
* @openapi
* /host-metrics/managers/services/{id}/action:
* post:
* summary: Start/stop/restart/enable/disable a systemd service
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* unit: { type: string }
* action: { type: string }
* responses:
* 200: { description: Action result. }
* 400: { description: Invalid unit or action. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"services_action",
async (client, host, req) => {
const { unit, action } = req.body as {
unit?: string;
action?: string;
};
if (!isValidSystemdUnit(unit)) {
throw new ManagerInputError("Invalid unit name");
}
if (!isValidServiceAction(action)) {
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(
client,
buildServiceActionCommand(unit, action),
host.sudoPassword,
{ forceSudo: true, timeoutMs: 30000 },
);
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
// ─── Top by memory ──────────────────────────────────────────────────────────
export interface MemProcessRow {
pid: number;
user: string;
mem: number;
rss: number;
command: string;
}
const TOP_MEM_CMD =
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
export function parseTopMemory(output: string): MemProcessRow[] {
const rows: MemProcessRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
if (!m) continue;
rows.push({
pid: Number(m[1]),
user: m[2],
mem: Number(m[3]),
rss: Number(m[4]),
command: m[5],
});
}
return rows;
}
// ─── Systemd timers ─────────────────────────────────────────────────────────
export interface TimerRow {
next: string;
left: string;
last: string;
unit: string;
activates: string;
}
const TIMERS_CMD =
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
export function parseTimers(output: string): TimerRow[] {
const rows: TimerRow[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("NEXT")) continue;
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
// the trailing UNIT + ACTIVATES which always end the line.
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const activates = parts[parts.length - 1];
const unit = parts[parts.length - 2];
if (!unit.endsWith(".timer")) continue;
rows.push({
next: parts.slice(0, 3).join(" "),
left: "",
last: "",
unit,
activates,
});
}
return rows;
}
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
export interface MountUsage {
filesystem: string;
sizeKb: number;
usedKb: number;
availKb: number;
usePct: number;
mount: string;
}
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
export function parseDfMounts(output: string): MountUsage[] {
const mounts: MountUsage[] = [];
for (const raw of output.split("\n")) {
const line = raw.trim();
if (!line) continue;
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
if (!m) continue;
const fs = m[1];
// Skip pseudo/virtual filesystems that clutter the view.
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
mounts.push({
filesystem: fs,
sizeKb: Number(m[2]),
usedKb: Number(m[3]),
availKb: Number(m[4]),
usePct: Number(m[5]),
mount: m[6],
});
}
return mounts;
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "read", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
);
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
);
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
);
}
+317
View File
@@ -0,0 +1,317 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "./validation.js";
import { detectPlatform } from "./platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
export interface CertInfo {
client: AcmeClient | "other";
name: string;
domains: string[];
expiry: string | null;
path?: string;
}
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
/** Parse `certbot certificates` output. */
export function parseCertbotCertificates(output: string): CertInfo[] {
const certs: CertInfo[] = [];
let current: CertInfo | null = null;
for (const raw of output.split("\n")) {
const line = raw.trim();
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
if (nameM) {
if (current) certs.push(current);
current = {
client: "certbot",
name: nameM[1],
domains: [],
expiry: null,
};
continue;
}
if (!current) continue;
const domM = line.match(/^Domains:\s+(.+)$/);
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
if (expM) current.expiry = expM[1];
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
if (pathM) current.path = pathM[1];
}
if (current) certs.push(current);
return certs;
}
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
export function parseAcmeShList(output: string): CertInfo[] {
const certs: CertInfo[] = [];
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length < 2) return certs;
for (const line of lines.slice(1)) {
const cols = line
.split(/\s{2,}|\t/)
.map((c) => c.trim())
.filter(Boolean);
if (cols.length < 1) continue;
certs.push({
client: "acme.sh",
name: cols[0],
domains: [cols[0]],
expiry: cols[cols.length - 1] || null,
});
}
return certs;
}
/**
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
*/
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
export function isValidCertName(name: unknown): name is string {
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
}
export interface IssueRequest {
client: AcmeClient;
domains: string[];
challenge: ChallengeType;
webroot?: string;
dnsProvider?: string;
}
/**
* Build the certificate issuance command for the chosen client/challenge.
* DNS-01 provider credentials are expected to already be present in the
* environment / provider config file; we never put secrets in argv.
*/
export function buildIssueCommand(req: IssueRequest): string {
const domains = req.domains;
if (req.client === "certbot") {
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
}
// acme.sh
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
if (req.challenge === "dns") {
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
}
if (req.challenge === "http-webroot") {
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
req.webroot as string,
)} ${dFlags}`;
}
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
}
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
if (client === "certbot") {
return `certbot renew${dryRun ? " --dry-run" : ""}`;
}
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
}
/**
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
* it from management. `name` is the certbot cert name or the acme.sh domain.
*/
export function buildRevokeCommand(client: AcmeClient, name: string): string {
if (client === "certbot") {
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
name,
)} --delete-after-revoke`;
}
const d = shellSingleQuote(name);
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
const r = await execElevated(
client,
CERTBOT_LIST_CMD,
host.sudoPassword,
).catch(() => null);
if (r) certs.push(...parseCertbotCertificates(r.stdout));
}
if (platform.hasAcmeSh) {
const { stdout } = await execCommand(
client,
ACMESH_LIST_CMD,
15000,
).catch(() => ({ stdout: "" }) as { stdout: string });
certs.push(...parseAcmeShList(stdout));
}
return {
clients: {
certbot: platform.hasCertbot,
acmeSh: platform.hasAcmeSh,
},
certs,
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
/**
* @openapi
* /host-metrics/managers/ssl/{id}/revoke:
* post:
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
* tags: [Host Metrics]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* client: { type: string, enum: [certbot, acme.sh] }
* name: { type: string, description: certbot cert name or acme.sh domain }
* responses:
* 200: { description: Revoke result. }
* 400: { description: Invalid client or certificate name. }
* 403: { description: Elevation required or denied. }
*/
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"execute",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: "read" | "execute",
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
}
+151
View File
@@ -0,0 +1,151 @@
import type { Express } from "express";
import { execCommand } from "../widgets/common-utils.js";
import { execElevated } from "./exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
name: string;
uid: number;
gid: number;
home: string;
shell: string;
}
export interface SystemGroup {
name: string;
gid: number;
members: string[];
}
// Human users only (uid >= 1000, excluding nobody at 65534).
const READ_USERS_CMD = "getent passwd 2>/dev/null";
const READ_GROUPS_CMD = "getent group 2>/dev/null";
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
export function parsePasswd(output: string): SystemUser[] {
const users: SystemUser[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 7) continue;
const uid = Number(parts[2]);
if (!Number.isFinite(uid)) continue;
if (uid < 1000 || uid === 65534) continue;
users.push({
name: parts[0],
uid,
gid: Number(parts[3]),
home: parts[5],
shell: parts[6],
});
}
return users;
}
export function parseGroups(output: string): SystemGroup[] {
const groups: SystemGroup[] = [];
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
groups.push({
name: parts[0],
gid: Number(parts[2]),
members: parts[3].split(",").filter(Boolean),
});
}
return groups;
}
export function parseSudoers(output: string): string[] {
const members = new Set<string>();
for (const line of output.split("\n")) {
const parts = line.split(":");
if (parts.length < 4) continue;
parts[3]
.split(",")
.filter(Boolean)
.forEach((m) => members.add(m));
}
return [...members];
}
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "read", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
execCommand(client, READ_SUDOERS_CMD, 15000),
]);
return {
users: parsePasswd(passwd.stdout),
groups: parseGroups(groups.stdout),
sudoers: parseSudoers(sudoers.stdout),
};
}),
);
app.post(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
"execute",
"users_action",
async (client, host, req) => {
const { action, username, group } = req.body as {
action?: UserAction;
username?: string;
group?: string;
};
if (!isValidUsername(username))
throw new ManagerInputError("Invalid username");
// Never modify/delete the user we're connected as, or root.
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
if (username === who || username === "root") {
throw new ManagerInputError(
"Refusing to modify the connected user or root",
);
}
let cmd: string;
switch (action) {
case "create":
cmd = `useradd -m ${username}`;
break;
case "delete":
cmd = `userdel -r ${username}`;
break;
case "addToGroup":
case "removeFromGroup":
if (!isValidGroupName(group))
throw new ManagerInputError("Invalid group");
cmd =
action === "addToGroup"
? `usermod -aG ${group} ${username}`
: `gpasswd -d ${username} ${group}`;
break;
default:
throw new ManagerInputError("Invalid action");
}
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
});
return {
success: result.code === 0,
output: result.stdout || result.stderr,
};
},
),
);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
| "start"
| "stop"
| "restart"
| "reload"
| "enable"
| "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
+6 -4
View File
@@ -69,7 +69,7 @@ async function createTemplateConfig(): Promise<void> {
const template = `
# OPKSSH Configuration
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
# Termix Documentation: https://docs.termix.site/opkssh
# Termix Documentation: https://docs.termix.site/features/authentication/opkssh
`;
try {
@@ -227,7 +227,7 @@ function validateRedirectUrisAreLocalhost(
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
`Docs: https://docs.termix.site/opkssh`,
`Docs: https://docs.termix.site/features/authentication/opkssh`,
};
}
@@ -410,7 +410,8 @@ export async function startOPKSSHAuth(
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
instructions: "See documentation: https://docs.termix.site/opkssh",
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
@@ -448,7 +449,8 @@ export async function startOPKSSHAuth(
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
`(e.g. in Google Cloud Console → OAuth client). ` +
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
instructions: "See documentation: https://docs.termix.site/opkssh",
instructions:
"See documentation: https://docs.termix.site/features/authentication/opkssh",
}),
);
await cleanup();
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Stub all external imports before loading the module under test
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
vi.mock("../database/db/index.js", () => ({
getDb: () => ({
$client: {
prepare: () => ({ get: () => undefined }),
},
insert: mockInsert,
}),
}));
vi.mock("../database/db/schema.js", () => ({
sessionRecordings: {},
}));
vi.mock("../utils/logger.js", () => ({
sshLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock individual fs.promises methods via a stub object
const mockMkdir = vi.fn().mockResolvedValue(undefined);
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
vi.mock("fs", () => ({
default: {
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
readFile: vi.fn(),
unlink: vi.fn(),
},
},
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
readFile: vi.fn(),
unlink: vi.fn(),
},
}));
const { sessionManager } = await import("./terminal-session-manager.js");
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-apply resolved values after clearAllMocks
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockInsertValues.mockResolvedValue(undefined);
mockInsert.mockReturnValue({ values: mockInsertValues });
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not write log file when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("writes log file and inserts DB row when sessionLoggingEnabled=true", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).toHaveBeenCalledOnce();
expect(mockInsert).toHaveBeenCalledOnce();
expect(mockInsertValues).toHaveBeenCalledOnce();
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(mockWriteFile).not.toHaveBeenCalled();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
+97 -1
View File
@@ -1,9 +1,14 @@
import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { sshLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js";
import { sessionRecordings } from "../database/db/schema.js";
const MAX_BUFFER_BYTES = 512 * 1024;
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const SESSION_LOGS_DIR = path.join(DATA_DIR, "session_logs");
const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
@@ -32,6 +37,9 @@ export interface TerminalSession {
outputBuffer: string[];
outputBufferBytes: number;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
}
class TerminalSessionManager {
@@ -60,6 +68,7 @@ class TerminalSessionManager {
cols: number,
rows: number,
tabInstanceId?: string,
sessionLoggingEnabled = true,
): string {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
@@ -107,6 +116,7 @@ class TerminalSessionManager {
}
const id = crypto.randomUUID();
const now = Date.now();
const session: TerminalSession = {
id,
userId,
@@ -119,13 +129,16 @@ class TerminalSessionManager {
cols,
rows,
isConnected: false,
createdAt: Date.now(),
createdAt: now,
attachedWs: null,
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
lastPersistedBytes: 0,
};
this.sessions.set(id, session);
@@ -288,6 +301,10 @@ class TerminalSessionManager {
session.attachedWs = null;
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
// regardless of whether the session is later reattached or times out.
this.maybePersistLog(session);
const timeoutMs = this.getTimeoutMs();
session.detachTimeout = setTimeout(() => {
@@ -316,6 +333,8 @@ class TerminalSessionManager {
session.detachTimeout = null;
}
this.maybePersistLog(session, true);
if (session.sshStream) {
try {
session.sshStream.end();
@@ -357,6 +376,83 @@ class TerminalSessionManager {
});
}
private stripAnsi(raw: string): string {
return (
raw
// ESC sequences: CSI, OSC, DCS, PM, APC, SOS
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "")
// Single-char ESC sequences (e.g. ESC M, ESC =)
.replace(/\x1b[^[\]PX^_]/g, "")
// Other C0/C1 control chars except newline, carriage return, tab
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
// Collapse carriage returns used for line overwrites
.replace(/[^\n]*\r(?!\n)/g, "")
);
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.outputBufferBytes === 0) return;
// Only save if new output arrived since last persist (unless forced)
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
return;
const snapshot = session.outputBuffer.join("");
session.lastPersistedBytes = session.outputBufferBytes;
this.persistSessionLog(session, snapshot).catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(
session: TerminalSession,
logContent: string,
): Promise<void> {
const cleaned = this.stripAnsi(logContent);
if (!cleaned.trim()) return;
const userLogDir = path.join(SESSION_LOGS_DIR, session.userId);
await fs.promises.mkdir(userLogDir, { recursive: true });
const logFile = path.join(userLogDir, `${session.id}.log`);
await fs.promises.writeFile(logFile, cleaned, "utf-8");
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const db = getDb();
await db.insert(sessionRecordings).values({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: logFile,
});
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
}
sshLogger.info("Session log persisted", {
operation: "session_log_persisted",
sessionId: session.id,
userId: session.userId,
hostId: session.hostId,
duration,
bytes: cleaned.length,
});
}
getUserSessions(userId: string): TerminalSession[] {
const result: TerminalSession[] = [];
for (const session of this.sessions.values()) {
+76 -5
View File
@@ -11,6 +11,7 @@ import { getDb } from "../database/db/index.js";
import { hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger, authLogger } from "../utils/logger.js";
import { logAudit } from "../utils/audit-logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
@@ -64,9 +65,13 @@ interface ConnectToHostData {
keepaliveCountMax?: number;
[key: string]: unknown;
};
enableSessionLogging?: boolean;
};
initialPath?: string;
executeCommand?: string;
/** Attach straight to this tmux session once the shell is ready
* (tmux monitor opens its panes through a real PTY this way). */
tmuxAttachSession?: string;
}
interface ResizeData {
@@ -822,7 +827,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
async function handleConnectToHost(data: ConnectToHostData) {
const { hostConfig, initialPath, executeCommand } = data;
const { hostConfig, initialPath, executeCommand, tmuxAttachSession } = data;
const {
id,
ip: rawIp,
@@ -959,6 +964,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
socks5Password?: string;
socks5ProxyChain?: unknown;
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
enableSessionLogging?: boolean;
})
| null = null;
@@ -1081,6 +1087,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostId: id,
ip,
});
logAudit({
userId,
username: userId,
action: "ssh_connect",
resourceType: "host",
resourceId: String(id),
resourceName: `${username}@${ip}:${port}`,
success: true,
});
if (totpPromptSent) {
authLogger.success("TOTP verification successful for SSH session", {
operation: "terminal_totp_success",
@@ -1095,6 +1111,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
const hostDisplayName = `${username}@${ip}:${port}`;
const tabInstanceId = hostConfig.instanceId;
const sessionLoggingEnabled =
resolvedHostData?.enableSessionLogging ??
hostConfig.enableSessionLogging ??
true;
currentSessionId = sessionManager.createSession(
userId,
id,
@@ -1102,6 +1122,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
data.cols,
data.rows,
tabInstanceId,
sessionLoggingEnabled,
);
// If createSession returned an existing live session (duplicate tabInstanceId),
@@ -1465,7 +1486,27 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, delay);
};
if (autoTmux && conn) {
if (tmuxAttachSession && conn) {
// Direct attach (tmux monitor): the session is known to exist, so
// skip detection and reuse the same path as the manual
// "tmux_attach" websocket message.
attachOrCreateTmuxSession(stream, tmuxAttachSession);
{
const session = sessionManager.getSession(boundSessionId);
if (session) session.tmuxSessionName = tmuxAttachSession;
}
sshLogger.info("Attached to requested tmux session", {
operation: "tmux_direct_attach",
sessionName: tmuxAttachSession,
hostId: id,
});
ws.send(
JSON.stringify({
type: "tmux_session_attached",
sessionName: tmuxAttachSession,
}),
);
} else if (autoTmux && conn) {
(async () => {
try {
const detection = await detectTmux(conn);
@@ -1699,6 +1740,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (
resolvedCredentials.authType === "tailscale" &&
(authMethodNotAvailable ||
err.message.includes("All configured authentication methods failed"))
) {
sendLog(
"auth",
"error",
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
);
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "error",
message:
"Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
}),
);
return;
}
if (
authMethodNotAvailable &&
resolvedCredentials.authType === "none" &&
@@ -1946,7 +2012,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
host: ip,
port,
username,
tryKeyboard: resolvedCredentials.authType !== "none",
tryKeyboard:
resolvedCredentials.authType !== "none" &&
resolvedCredentials.authType !== "tailscale",
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? hostKeepaliveInterval * 1000
@@ -2017,8 +2085,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
},
};
if (resolvedCredentials.authType === "none") {
// no credentials needed
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
// Tailscale SSH and "none" auth: daemon handles authorization, no credentials needed
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
@@ -0,0 +1,225 @@
import { describe, it, expect } from "vitest";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
} from "./tmux-monitor-helpers.js";
function join(...fields: (string | number)[]): string {
return fields.join(SEP);
}
describe("parseSessions", () => {
it("parses tmux list-sessions output", () => {
const output = [
join("training", 1760000000, 1760001000, 1),
join("lab|with|pipes", 1760000500, 1760002000, 0),
].join("\n");
const sessions = parseSessions(output);
expect(sessions).toHaveLength(2);
expect(sessions[0]).toEqual({
name: "training",
created: 1760000000,
lastActivity: 1760001000,
attachedClients: 1,
});
// Session names containing "|" survive because SEP is a multi-char token
expect(sessions[1].name).toBe("lab|with|pipes");
expect(sessions[1].attachedClients).toBe(0);
});
it("returns empty array for empty output", () => {
expect(parseSessions("")).toEqual([]);
});
});
describe("parseWindows", () => {
it("groups windows by session", () => {
const output = [
join("training", 0, 1, "vim"),
join("training", 1, 0, "logs"),
join("api", 0, 1, "server"),
].join("\n");
const windows = parseWindows(output);
expect(windows.get("training")).toHaveLength(2);
expect(windows.get("training")![0]).toMatchObject({
index: 0,
name: "vim",
active: true,
});
expect(windows.get("api")![0].name).toBe("server");
});
});
describe("parsePanes", () => {
it("parses full pane lines including free-text fields", () => {
const output = join(
"training",
0,
"%3",
1,
12345,
1,
120,
40,
"python",
"/home/user/my|dir",
"gpu01: train.py",
);
const panes = parsePanes(output);
expect(panes).toHaveLength(1);
expect(panes[0]).toEqual({
sessionName: "training",
windowIndex: 0,
id: "%3",
index: 1,
pid: 12345,
active: true,
width: 120,
height: 40,
command: "python",
path: "/home/user/my|dir",
title: "gpu01: train.py",
});
});
});
describe("parsePsOutput", () => {
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
const output = [
" 1 0 0.0 0.1 1234 systemd",
"12345 1 2.5 1.0 50000 bash",
"12400 12345 95.3 12.5 800000 python3",
"garbage line",
].join("\n");
const procs = parsePsOutput(output);
expect(procs).toHaveLength(3);
expect(procs[2]).toEqual({
pid: 12400,
ppid: 12345,
cpu: 95.3,
mem: 12.5,
rss: 800000,
comm: "python3",
});
});
});
describe("parseGpuOutput", () => {
it("parses nvidia-smi csv output and sums per pid", () => {
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
const gpu = parseGpuOutput(output);
expect(gpu.get(12400)).toBe(10000);
expect(gpu.get(99999)).toBe(512);
});
it("handles empty output (no GPU)", () => {
expect(parseGpuOutput("").size).toBe(0);
});
});
describe("buildPaneMetrics", () => {
const panes = parsePanes(
[
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
].join("\n"),
);
const processes = parsePsOutput(
[
// pane %1: bash(100) -> python3(110) -> worker(111)
" 100 1 0.1 0.1 4000 bash",
" 110 100 90.0 10.0 700000 python3",
" 111 110 9.5 2.0 100000 dataloader",
// pane %2: bash(200) only
" 200 1 0.0 0.1 4000 bash",
// unrelated process
" 300 1 50.0 5.0 200000 chrome",
].join("\n"),
);
it("aggregates descendant trees per pane", () => {
const metrics = buildPaneMetrics(panes, processes, new Map());
const m1 = metrics.find((m) => m.paneId === "%1")!;
expect(m1.processCount).toBe(3);
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
expect(m1.memRssKb).toBe(804000);
expect(m1.topCommand).toBe("python3");
const m2 = metrics.find((m) => m.paneId === "%2")!;
expect(m2.processCount).toBe(1);
expect(m2.cpuPercent).toBe(0);
// Unrelated process is never attributed
expect(m2.memRssKb).toBe(4000);
});
it("attributes GPU memory through the process tree", () => {
const gpu = new Map([
[110, 8000],
[300, 4000],
]);
const metrics = buildPaneMetrics(panes, processes, gpu);
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
});
it("handles a pane whose pid is missing from ps output", () => {
const orphan = parsePanes(
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
);
const metrics = buildPaneMetrics(orphan, processes, new Map());
expect(metrics[0].processCount).toBe(0);
expect(metrics[0].cpuPercent).toBe(0);
expect(metrics[0].topCommand).toBeNull();
});
it("does not loop on cyclic ppid data", () => {
const cyclic = parsePsOutput(
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
);
const pane = parsePanes(
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
);
const metrics = buildPaneMetrics(pane, cyclic, new Map());
expect(metrics[0].processCount).toBe(2);
});
});
describe("attachPanesToWindows", () => {
it("places panes into their windows", () => {
const windows = parseWindows(
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
);
const panes = parsePanes(
[
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
].join("\n"),
);
attachPanesToWindows(windows, panes);
expect(windows.get("s1")![0].panes).toHaveLength(1);
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
});
});
describe("shellEscape", () => {
it("wraps in single quotes and escapes embedded quotes", () => {
expect(shellEscape("simple")).toBe("'simple'");
expect(shellEscape("it's")).toBe("'it'\\''s'");
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
});
});
+240
View File
@@ -0,0 +1,240 @@
// Pure parsing/aggregation helpers for the tmux monitor module.
// Kept free of SSH/Express dependencies so they can be unit-tested.
// Field separator used in tmux -F format strings. Session names, pane titles
// and paths may contain "|", and tmux sanitizes control characters (and, under
// non-UTF-8 locales, multibyte characters) in format output to "_", so a
// printable ASCII token is the only separator that survives everywhere.
export const SEP = "<<TMX>>";
export interface TmuxPane {
id: string;
index: number;
pid: number;
active: boolean;
width: number;
height: number;
command: string;
path: string;
title: string;
}
export interface TmuxWindow {
index: number;
name: string;
active: boolean;
panes: TmuxPane[];
}
export interface TmuxSessionSummary {
name: string;
created: number;
lastActivity: number;
attachedClients: number;
}
export interface RawPane extends TmuxPane {
sessionName: string;
windowIndex: number;
}
export interface ProcessInfo {
pid: number;
ppid: number;
cpu: number;
mem: number;
rss: number;
comm: string;
}
export interface PaneMetrics {
paneId: string;
sessionName: string;
pid: number;
processCount: number;
cpuPercent: number;
memRssKb: number;
gpuMemMb: number;
topCommand: string | null;
}
export function parseSessions(output: string): TmuxSessionSummary[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, created, activity, attached] = line.split(SEP);
return {
name,
created: parseInt(created, 10) || 0,
lastActivity: parseInt(activity, 10) || 0,
attachedClients: parseInt(attached, 10) || 0,
};
});
}
export function parseWindows(output: string): Map<string, TmuxWindow[]> {
const bySession = new Map<string, TmuxWindow[]>();
for (const line of output.split("\n").filter(Boolean)) {
const [session, index, active, name] = line.split(SEP);
if (!bySession.has(session)) bySession.set(session, []);
bySession.get(session)!.push({
index: parseInt(index, 10) || 0,
name: name || "",
active: active === "1",
panes: [],
});
}
return bySession;
}
export function parsePanes(output: string): RawPane[] {
return output
.split("\n")
.filter(Boolean)
.map((line) => {
const [
sessionName,
windowIndex,
id,
index,
pid,
active,
width,
height,
command,
path,
title,
] = line.split(SEP);
return {
sessionName,
windowIndex: parseInt(windowIndex, 10) || 0,
id,
index: parseInt(index, 10) || 0,
pid: parseInt(pid, 10) || 0,
active: active === "1",
width: parseInt(width, 10) || 0,
height: parseInt(height, 10) || 0,
command: command || "",
path: path || "",
title: title || "",
};
});
}
export function parsePsOutput(output: string): ProcessInfo[] {
const processes: ProcessInfo[] = [];
for (const line of output.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
const pid = parseInt(parts[0], 10);
const ppid = parseInt(parts[1], 10);
if (isNaN(pid) || isNaN(ppid)) continue;
processes.push({
pid,
ppid,
cpu: parseFloat(parts[2]) || 0,
mem: parseFloat(parts[3]) || 0,
rss: parseInt(parts[4], 10) || 0,
comm: parts.slice(5).join(" "),
});
}
return processes;
}
export function parseGpuOutput(output: string): Map<number, number> {
const gpuByPid = new Map<number, number>();
for (const line of output.split("\n").filter(Boolean)) {
const [pid, mem] = line.split(",").map((s) => s.trim());
const pidNum = parseInt(pid, 10);
const memNum = parseInt(mem, 10);
if (!isNaN(pidNum) && !isNaN(memNum)) {
gpuByPid.set(pidNum, (gpuByPid.get(pidNum) || 0) + memNum);
}
}
return gpuByPid;
}
/**
* Map each pane's shell pid to its descendant process tree and aggregate
* CPU/RAM/GPU usage per pane.
*/
export function buildPaneMetrics(
panes: RawPane[],
processes: ProcessInfo[],
gpuByPid: Map<number, number>,
): PaneMetrics[] {
const byPid = new Map<number, ProcessInfo>();
const childrenOf = new Map<number, number[]>();
for (const p of processes) {
byPid.set(p.pid, p);
if (!childrenOf.has(p.ppid)) childrenOf.set(p.ppid, []);
childrenOf.get(p.ppid)!.push(p.pid);
}
return panes.map((pane) => {
// Walk the descendant tree starting at (and including) the pane's shell
const treePids: number[] = [];
const queue = [pane.pid];
const seen = new Set<number>();
while (queue.length > 0) {
const pid = queue.shift()!;
if (seen.has(pid)) continue;
seen.add(pid);
if (byPid.has(pid)) treePids.push(pid);
for (const child of childrenOf.get(pid) || []) queue.push(child);
}
let cpuPercent = 0;
let memRssKb = 0;
let gpuMemMb = 0;
let topCommand: string | null = null;
let topCpu = -1;
for (const pid of treePids) {
const p = byPid.get(pid)!;
cpuPercent += p.cpu;
memRssKb += p.rss;
gpuMemMb += gpuByPid.get(pid) || 0;
// The pane shell itself is rarely the interesting process
if (p.cpu > topCpu && pid !== pane.pid) {
topCpu = p.cpu;
topCommand = p.comm;
}
}
if (topCommand === null && treePids.length > 0) {
topCommand = byPid.get(treePids[0])!.comm;
}
return {
paneId: pane.id,
sessionName: pane.sessionName,
pid: pane.pid,
processCount: treePids.length,
cpuPercent: Math.round(cpuPercent * 10) / 10,
memRssKb,
gpuMemMb,
topCommand,
};
});
}
/**
* Group panes into their windows (mutates the window objects' pane arrays).
*/
export function attachPanesToWindows(
windows: Map<string, TmuxWindow[]>,
panes: RawPane[],
): void {
for (const pane of panes) {
const sessionWindows = windows.get(pane.sessionName) || [];
const window = sessionWindows.find((w) => w.index === pane.windowIndex);
if (window) {
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
window.panes.push(paneFields);
}
}
}
export function shellEscape(s: string): string {
return "'" + s.replace(/'/g, "'\\''") + "'";
}
+928
View File
@@ -0,0 +1,928 @@
import express from "express";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { eq, and } from "drizzle-orm";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { AuthManager } from "../utils/auth-manager.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { tmuxSessionTags, users } from "../database/db/schema.js";
import { logAudit, getRequestMeta } from "../utils/audit-logger.js";
import { sshLogger } from "../utils/logger.js";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { resolveHostById, checkHostAccess } from "./host-resolver.js";
import { createJumpHostChain } from "./jump-host-chain.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { withConnection } from "./ssh-connection-pool.js";
import { execCommand } from "./tmux-helper.js";
import {
SEP,
parseSessions,
parseWindows,
parsePanes,
parsePsOutput,
parseGpuOutput,
buildPaneMetrics,
attachPanesToWindows,
shellEscape,
type RawPane,
type TmuxSessionSummary,
type TmuxWindow,
type PaneMetrics,
} from "./tmux-monitor-helpers.js";
import type { SSHHost, AuthenticatedRequest } from "../../types/index.js";
const PANE_ID_RE = /^%\d+$/;
// tmux session names cannot contain ":" or "."; keep to a conservative
// printable subset so the name is safe as a tmux target everywhere.
const SESSION_NAME_RE = /^[A-Za-z0-9_@%+=-]{1,64}$/;
const MAX_SEARCH_PANES = 100;
const MAX_MATCHES_PER_PANE = 50;
const SEARCH_HISTORY_LINES = 2000;
const SEARCH_CONCURRENCY = 4;
interface TmuxSessionOverview extends TmuxSessionSummary {
windows: TmuxWindow[];
tags: string[];
}
// ---------------------------------------------------------------------------
// SSH connection (lean variant of the per-module pattern used by server-stats
// and docker; jump hosts and SOCKS5 reuse the shared helpers)
// ---------------------------------------------------------------------------
async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
const base: ConnectConfig = {
host: (host.ip || "").replace(/^\[|\]$/g, ""),
port: host.port,
username: host.username,
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
host.id,
host.ip,
host.port,
null,
host.userId || "",
false,
),
algorithms: SSH_ALGORITHMS,
} as ConnectConfig;
if (host.authType === "password") {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
base.password = host.password;
} else if (host.authType === "key") {
if (!host.key || !host.key.includes("-----BEGIN")) {
throw new Error(`No valid SSH key available for host ${host.ip}`);
}
const cleanKey = host.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
(base as Record<string, unknown>).privateKey = Buffer.from(
cleanKey,
"utf8",
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} else if (host.authType === "none") {
// no credentials needed
} else {
// opkssh and other interactive flows are not supported by this module
throw new Error(
`Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`,
);
}
return base;
}
export function connectToHost(host: SSHHost): () => Promise<Client> {
return async () => {
const config = await buildSshConfig(host);
const client = new Client();
const proxyConfig: SOCKS5Config | null =
host.useSocks5 &&
(host.socks5Host ||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
? {
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
}
: null;
let jumpClient: Client | null = null;
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
jumpClient = await createJumpHostChain(
host.jumpHosts,
host.userId,
proxyConfig,
);
if (!jumpClient) {
throw new Error("Failed to establish jump host chain");
}
} else if (proxyConfig) {
const proxySocket = await createSocks5Connection(
host.ip,
host.port,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
}
}
return new Promise<Client>((resolve, reject) => {
const timeout = setTimeout(() => {
client.end();
jumpClient?.end();
reject(new Error("SSH connection timeout"));
}, 30000);
client.on("ready", () => {
clearTimeout(timeout);
resolve(client);
});
client.on("error", (err) => {
clearTimeout(timeout);
jumpClient?.end();
reject(err);
});
client.on(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
finish(
prompts.map((p) =>
/password/i.test(p.prompt) ? host.password || "" : "",
),
);
},
);
if (jumpClient) {
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port,
(err, stream) => {
if (err) {
clearTimeout(timeout);
jumpClient!.end();
reject(
new Error(
"Failed to forward through jump host: " + err.message,
),
);
return;
}
config.sock = stream;
client.connect(config);
},
);
} else {
client.connect(config);
}
});
};
}
function getPoolKey(host: SSHHost): string {
const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}`
: "";
return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
}
async function withHostConnection<T>(
host: SSHHost,
fn: (client: Client) => Promise<T>,
): Promise<T> {
return withConnection(getPoolKey(host), connectToHost(host), fn);
}
// ---------------------------------------------------------------------------
// tmux queries
// ---------------------------------------------------------------------------
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, "command -v tmux");
return true;
} catch {
return false;
}
}
async function runTmuxList(conn: Client, command: string): Promise<string> {
try {
return await execCommand(conn, command);
} catch {
return ""; // tmux server not running -- no sessions
}
}
function listSessionsCmd(): string {
return `tmux list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`;
}
function listWindowsCmd(): string {
return `tmux list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`;
}
function listPanesCmd(): string {
return `tmux list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`;
}
async function listPanesRaw(conn: Client): Promise<RawPane[]> {
return parsePanes(await runTmuxList(conn, listPanesCmd()));
}
async function fetchSessionTags(
userId: string,
hostId: number,
): Promise<Map<string, string[]>> {
const rows = await getDb()
.select()
.from(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, hostId),
),
);
const bySession = new Map<string, string[]>();
for (const row of rows) {
if (!bySession.has(row.sessionName)) bySession.set(row.sessionName, []);
bySession.get(row.sessionName)!.push(row.tag);
}
return bySession;
}
async function collectPaneMetrics(
conn: Client,
panes: RawPane[],
): Promise<PaneMetrics[]> {
let psOutput = "";
try {
psOutput = await execCommand(
conn,
"ps -eo pid=,ppid=,pcpu=,pmem=,rss=,comm= 2>/dev/null",
);
} catch {
return [];
}
// GPU memory per pid (best effort; nvidia-smi may not exist)
let gpuOutput = "";
try {
gpuOutput = await execCommand(
conn,
"command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null || true",
);
} catch {
// no GPU on host
}
return buildPaneMetrics(
panes,
parsePsOutput(psOutput),
parseGpuOutput(gpuOutput),
);
}
// ---------------------------------------------------------------------------
// Express app
// ---------------------------------------------------------------------------
const app = express();
const authManager = AuthManager.getInstance();
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use(authManager.createAuthMiddleware());
/**
* Resolve the host for a request and verify the user can access it.
* Sends the error response and returns null when access is denied.
*/
async function requireHost(
req: express.Request,
res: express.Response,
permission: "read" | "execute" = "read",
): Promise<SSHHost | null> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.hostId), 10);
if (isNaN(hostId)) {
res.status(400).json({ error: "Invalid host ID" });
return null;
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
res.status(401).json({ error: "User data is locked" });
return null;
}
let host: SSHHost | null = null;
try {
host = await resolveHostById(hostId, userId);
} catch (err) {
sshLogger.error(`Failed to resolve host ${hostId} for tmux monitor`, err);
}
if (!host) {
res.status(404).json({ error: "Host not found" });
return null;
}
const hasAccess = await checkHostAccess(
hostId,
userId,
host.userId || userId,
permission,
);
if (!hasAccess) {
res.status(403).json({ error: "Access denied" });
return null;
}
// The monitor is opt-in per host (same pattern as enableDocker in
// docker.ts): hiding the UI is not enough, the API must refuse too.
if (!host.enableTmuxMonitor) {
res
.status(403)
.json({ error: "Tmux Monitor is not enabled for this host" });
return null;
}
return host;
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : "Unknown error";
}
// Destructive tmux actions terminate processes on the remote host, so they
// land in the audit log like other host-level mutations (see host.ts).
async function auditTmuxAction(
req: express.Request,
host: SSHHost,
action: string,
resourceName: string,
details?: Record<string, unknown>,
): Promise<void> {
const userId = (req as unknown as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
let username = userId;
try {
const actor = await getDb()
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
username = actor[0]?.username ?? userId;
} catch {
// fall back to the raw user id
}
await logAudit({
userId,
username,
action,
resourceType: "host",
resourceId: String(host.id),
resourceName,
details: details ? JSON.stringify(details) : undefined,
ipAddress,
userAgent,
success: true,
});
}
// Typed error codes so the frontend can render a helpful state instead of a
// raw 500 (same pattern as SESSION_EXPIRED handling in main-axios).
type TmuxErrorCode =
| "TMUX_NOT_INSTALLED"
| "TMUX_NO_SERVER"
| "HOST_UNREACHABLE"
| "TMUX_ERROR";
function classifyTmuxError(err: unknown): TmuxErrorCode {
const msg = err instanceof Error ? err.message : "";
if (/command not found|exited with code 127/i.test(msg))
return "TMUX_NOT_INSTALLED";
if (/no server running|lost server/i.test(msg)) return "TMUX_NO_SERVER";
if (
/timeout|timed out|econnrefused|ehostunreach|enotfound|enetunreach|econnreset|authentication|handshake|keepalive/i.test(
msg,
)
)
return "HOST_UNREACHABLE";
return "TMUX_ERROR";
}
function sendTmuxError(
res: express.Response,
err: unknown,
context: string,
hostId: number,
): void {
const code = classifyTmuxError(err);
const status = code === "TMUX_ERROR" ? 500 : 503;
const error =
code === "TMUX_NOT_INSTALLED"
? "tmux is not installed on this host"
: code === "TMUX_NO_SERVER"
? "No tmux server is running on this host"
: code === "HOST_UNREACHABLE"
? "Could not connect to the host"
: toErrorMessage(err);
sshLogger.error(`tmux ${context} failed for host ${hostId}`, err);
res.status(status).json({ error, code });
}
app.get("/tmux_monitor/:hostId/overview", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
try {
const result = await withHostConnection(host, async (conn) => {
if (!(await tmuxAvailable(conn))) {
return { available: false, sessions: [] as TmuxSessionOverview[] };
}
const [sessionsOut, windowsOut, panesOut] = await Promise.all([
runTmuxList(conn, listSessionsCmd()),
runTmuxList(conn, listWindowsCmd()),
runTmuxList(conn, listPanesCmd()),
]);
const sessions = parseSessions(sessionsOut);
const windows = parseWindows(windowsOut);
attachPanesToWindows(windows, parsePanes(panesOut));
const tags = await fetchSessionTags(userId, host.id);
const full: TmuxSessionOverview[] = sessions.map((s) => ({
...s,
windows: windows.get(s.name) || [],
tags: tags.get(s.name) || [],
}));
return { available: true, sessions: full };
});
res.json(result);
} catch (err) {
sendTmuxError(res, err, "overview", host.id);
}
});
// Focus a pane: select its window and pane on the server so every attached
// client (including the monitor's embedded terminal) switches to it.
app.post("/tmux_monitor/:hostId/focus", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
// A pane id is a valid window target: tmux resolves it to the window
// containing the pane.
execCommand(
conn,
`tmux select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`,
),
);
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "focus", host.id);
}
});
// Create a detached session. Starts the tmux server if none is running.
app.post("/tmux_monitor/:hostId/sessions", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const name = String((req.body as { name?: string })?.name || "").trim();
if (!SESSION_NAME_RE.test(name)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux new-session -d -s ${shellEscape(name)}`),
);
sshLogger.info("tmux session created", {
operation: "tmux_session_create",
hostId: host.id,
sessionName: name,
});
res.json({ ok: true, name });
} catch (err) {
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "create session", host.id);
}
});
// Create a window in an existing session. The session name comes from tmux's
// own listing, so it is only checked for characters that would change the
// target's meaning (":" and "." are window/pane separators in tmux targets);
// "=" prefixes the target for an exact-name match.
app.post("/tmux_monitor/:hostId/windows", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux new-window -t ${shellEscape(`=${sessionName}`)}`),
);
sshLogger.info("tmux window created", {
operation: "tmux_window_create",
hostId: host.id,
sessionName,
});
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "create window", host.id);
}
});
// Rename a session. Saved tags follow the session to its new name (for every
// user — the session itself is shared on the host).
app.post("/tmux_monitor/:hostId/rename", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const body = req.body as { sessionName?: string; newName?: string };
const sessionName = String(body?.sessionName || "").trim();
const newName = String(body?.newName || "").trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!SESSION_NAME_RE.test(newName)) {
return res.status(400).json({ error: "Invalid new session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`,
),
);
await getDb()
.update(tmuxSessionTags)
.set({ sessionName: newName })
.where(
and(
eq(tmuxSessionTags.hostId, host.id),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
sshLogger.info("tmux session renamed", {
operation: "tmux_session_rename",
hostId: host.id,
sessionName,
newName,
});
await auditTmuxAction(req, host, "tmux_session_rename", sessionName, {
newName,
});
res.json({ ok: true, name: newName });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
if (/duplicate session/i.test(toErrorMessage(err))) {
return res
.status(409)
.json({ error: "A session with this name already exists" });
}
sendTmuxError(res, err, "rename session", host.id);
}
});
// Kill a session and drop its saved tags.
app.post("/tmux_monitor/:hostId/kill", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const sessionName = String(
(req.body as { sessionName?: string })?.sessionName || "",
).trim();
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux kill-session -t ${shellEscape(`=${sessionName}`)}`,
),
);
await getDb()
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.hostId, host.id),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
sshLogger.info("tmux session killed", {
operation: "tmux_session_kill",
hostId: host.id,
sessionName,
});
await auditTmuxAction(req, host, "tmux_session_kill", sessionName);
res.json({ ok: true });
} catch (err) {
if (/can't find session|no such session/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Session not found" });
}
sendTmuxError(res, err, "kill session", host.id);
}
});
// Kill a window (and every pane in it). Killing the last window of a session
// ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const body = req.body as { sessionName?: string; windowIndex?: number };
const sessionName = String(body?.sessionName || "").trim();
const windowIndex = Number(body?.windowIndex);
if (!sessionName || /[:.\n]/.test(sessionName)) {
return res.status(400).json({ error: "Invalid session name" });
}
if (!Number.isInteger(windowIndex) || windowIndex < 0) {
return res.status(400).json({ error: "Invalid window index" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`,
),
);
sshLogger.info("tmux window killed", {
operation: "tmux_window_kill",
hostId: host.id,
sessionName,
windowIndex,
});
await auditTmuxAction(req, host, "tmux_window_kill", sessionName, {
windowIndex,
});
res.json({ ok: true });
} catch (err) {
if (
/can't find window|no such window|can't find session/i.test(
toErrorMessage(err),
)
) {
return res.status(404).json({ error: "Window not found" });
}
sendTmuxError(res, err, "kill window", host.id);
}
});
// Kill a single pane. Killing the last pane of a window closes the window,
// and the last window of a session ends the session — tmux semantics.
app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const paneId = String((req.body as { paneId?: string })?.paneId || "");
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(conn, `tmux kill-pane -t ${shellEscape(paneId)}`),
);
sshLogger.info("tmux pane killed", {
operation: "tmux_pane_kill",
hostId: host.id,
paneId,
});
await auditTmuxAction(req, host, "tmux_pane_kill", paneId);
res.json({ ok: true });
} catch (err) {
if (/can't find pane|no such pane/i.test(toErrorMessage(err))) {
return res.status(404).json({ error: "Pane not found" });
}
sendTmuxError(res, err, "kill pane", host.id);
}
});
// Split the window containing a pane. "h" places the new pane to the right,
// "v" below — matching tmux's own -h/-v semantics. The new pane starts in the
// source pane's working directory.
app.post("/tmux_monitor/:hostId/split", async (req, res) => {
const host = await requireHost(req, res, "execute");
if (!host) return;
const body = req.body as { paneId?: string; direction?: string };
const paneId = String(body?.paneId || "");
const direction = body?.direction === "v" ? "-v" : "-h";
if (!PANE_ID_RE.test(paneId)) {
return res.status(400).json({ error: "Invalid pane ID" });
}
if (body?.direction !== "h" && body?.direction !== "v") {
return res.status(400).json({ error: "Invalid split direction" });
}
try {
await withHostConnection(host, (conn) =>
execCommand(
conn,
`tmux split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`,
),
);
sshLogger.info("tmux pane split", {
operation: "tmux_pane_split",
hostId: host.id,
paneId,
direction: body.direction,
});
res.json({ ok: true });
} catch (err) {
sendTmuxError(res, err, "split", host.id);
}
});
app.get("/tmux_monitor/:hostId/search", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
const query = String(req.query.q || "").trim();
if (!query) {
return res.status(400).json({ error: "Missing search query" });
}
try {
const results = await withHostConnection(host, async (conn) => {
const allPanes = await listPanesRaw(conn);
const panes = allPanes.slice(0, MAX_SEARCH_PANES);
// Flips to true whenever a limit was hit, so the UI can tell the user
// the results are partial instead of silently truncating.
let truncated = allPanes.length > MAX_SEARCH_PANES;
const matches: Array<{
paneId: string;
sessionName: string;
windowIndex: number;
line: number;
text: string;
}> = [];
// Bounded concurrency; each search runs capture+grep remotely so only
// matching lines travel back over the wire.
for (let i = 0; i < panes.length; i += SEARCH_CONCURRENCY) {
const batch = panes.slice(i, i + SEARCH_CONCURRENCY);
await Promise.all(
batch.map(async (pane) => {
try {
const output = await execCommand(
conn,
`tmux capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`,
);
const lines = output.split("\n").filter(Boolean);
if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true;
for (const line of lines) {
const sep = line.indexOf(":");
if (sep === -1) continue;
matches.push({
paneId: pane.id,
sessionName: pane.sessionName,
windowIndex: pane.windowIndex,
line: parseInt(line.slice(0, sep), 10) || 0,
text: line.slice(sep + 1).slice(0, 500),
});
}
} catch {
// grep exits non-zero when there are no matches -- not an error
}
}),
);
}
return { matches, truncated };
});
res.json({
query,
matches: results.matches,
truncated: results.truncated,
searchedLines: SEARCH_HISTORY_LINES,
maxPanes: MAX_SEARCH_PANES,
});
} catch (err) {
sendTmuxError(res, err, "search", host.id);
}
});
app.get("/tmux_monitor/:hostId/metrics", async (req, res) => {
const host = await requireHost(req, res);
if (!host) return;
try {
const metrics = await withHostConnection(host, async (conn) => {
const panes = await listPanesRaw(conn);
if (panes.length === 0) return [];
return collectPaneMetrics(conn, panes);
});
res.json({ panes: metrics });
} catch (err) {
sendTmuxError(res, err, "metrics", host.id);
}
});
app.put("/tmux_monitor/:hostId/tags", async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const host = await requireHost(req, res);
if (!host) return;
const { sessionName, tags } = req.body as {
sessionName?: string;
tags?: string[];
};
if (!sessionName || typeof sessionName !== "string") {
return res.status(400).json({ error: "Missing session name" });
}
if (!Array.isArray(tags) || tags.some((t) => typeof t !== "string")) {
return res.status(400).json({ error: "Tags must be an array of strings" });
}
const cleanTags = [
...new Set(tags.map((t) => t.trim().slice(0, 64)).filter(Boolean)),
].slice(0, 20);
try {
const db = getDb();
await db
.delete(tmuxSessionTags)
.where(
and(
eq(tmuxSessionTags.userId, userId),
eq(tmuxSessionTags.hostId, host.id),
eq(tmuxSessionTags.sessionName, sessionName),
),
);
if (cleanTags.length > 0) {
await db.insert(tmuxSessionTags).values(
cleanTags.map((tag) => ({
userId,
hostId: host.id,
sessionName,
tag,
})),
);
}
await DatabaseSaveTrigger.triggerSave("tmux_session_tags_updated");
res.json({ sessionName, tags: cleanTags });
} catch (err) {
sshLogger.error(
`Failed to save tmux session tags for host ${host.id}`,
err,
);
res.status(500).json({ error: toErrorMessage(err) });
}
});
const PORT = 30010;
app.listen(PORT, () => {});
+15
View File
@@ -24,6 +24,7 @@ import type {
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
import { logAudit } from "../utils/audit-logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { DataCrypto } from "../utils/data-crypto.js";
@@ -1186,6 +1187,20 @@ async function connectSSHTunnel(
endpointPort: tunnelConfig.endpointPort,
});
logAudit({
userId: tunnelConfig.sourceUserId,
username: tunnelConfig.sourceUserId,
action: "tunnel_connect",
resourceType: "tunnel",
resourceId: String(tunnelConfig.sourceHostId),
resourceName: tunnelName,
details: JSON.stringify({
mode: getTunnelMode(tunnelConfig),
sourcePort: tunnelConfig.sourcePort,
}),
success: true,
});
broadcastTunnelStatus(tunnelName, {
connected: true,
status: CONNECTION_STATES.CONNECTED,