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
+47 -4
View File
@@ -1,9 +1,19 @@
version: 2
updates:
# npm dependencies (single root package.json, no workspaces)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
day: "monday"
open-pull-requests-limit: 15
labels:
- "dependencies"
- "npm"
commit-message:
prefix: "chore"
prefix-development: "chore"
include: "scope"
groups:
dev-patch-updates:
dependency-type: "development"
@@ -21,20 +31,53 @@ updates:
dependency-type: "production"
update-types:
- "minor"
# Major bumps grouped so they land as a single reviewable PR instead of
# one-per-package noise. These often need manual follow-up (Electron,
# React, Vite, Tailwind, Express 5, etc.).
major-updates:
update-types:
- "major"
# Docker base images (docker/Dockerfile + docker-compose / compose-dev)
- package-ecosystem: "docker"
directory: "/docker"
schedule:
interval: "daily"
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "docker"
commit-message:
prefix: "chore"
include: "scope"
groups:
patch-updates:
docker-patch-updates:
update-types:
- "patch"
minor-updates:
docker-minor-updates:
update-types:
- "minor"
docker-major-updates:
update-types:
- "major"
# GitHub Actions used across the workflows in .github/workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"
groups:
github-actions:
update-types:
- "patch"
- "minor"
- "major"
+157
View File
@@ -0,0 +1,157 @@
name: Retarget and Merge Dependabot PRs
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
retarget-and-merge:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Resolve newest dev branch
id: dev
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
# The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
# as "nothing to do" rather than a workflow failure.
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
echo "Newest dev branch: $DEV_BRANCH"
echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
else
echo "No dev-X.Y.Z branch open; nothing to retarget."
echo "branch=" >> "$GITHUB_OUTPUT"
fi
- name: Retarget and merge Dependabot PRs
if: ${{ steps.dev.outputs.branch != '' }}
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
DEV_BRANCH: ${{ steps.dev.outputs.branch }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
CONFLICT_LABEL="dependabot-rebase-requested"
# Ensure the bookkeeping label exists (no-op if it already does).
gh label create "$CONFLICT_LABEL" --repo "$REPO" \
--color "D93F0B" --description "Retarget workflow asked Dependabot to rebase a conflicting PR" \
2>/dev/null || true
# True if the PR already carries the conflict label.
has_conflict_label() {
gh pr view "$1" --repo "$REPO" --json labels \
-q '.labels[].name' | grep -qx "$CONFLICT_LABEL"
}
# Wait until GitHub has a definite mergeable verdict for a PR (it
# returns UNKNOWN while recomputing after a base change or a push).
# Echoes "<mergeable> <mergeStateStatus>".
wait_for_verdict() {
local pr="$1" mergeable state
for _ in $(seq 1 30); do
read -r mergeable state < <(gh pr view "$pr" --repo "$REPO" \
--json mergeable,mergeStateStatus \
-q '.mergeable + " " + .mergeStateStatus')
if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then
echo "$mergeable $state"
return 0
fi
sleep 20
done
echo "$mergeable $state"
}
# Phase 1: retarget every open Dependabot PR from main onto the dev
# branch. This kicks off a Dependabot rebase for each.
PR_NUMBERS=$(gh pr list --repo "$REPO" \
--author "app/dependabot" \
--base main \
--state open \
--json number -q '.[].number')
# Pick up PRs already sitting on the dev branch from a previous run too.
PR_NUMBERS="$PR_NUMBERS $(gh pr list --repo "$REPO" \
--author "app/dependabot" \
--base "$DEV_BRANCH" \
--state open \
--json number -q '.[].number')"
PR_NUMBERS=$(printf '%s\n' $PR_NUMBERS | sort -un)
if [ -z "$PR_NUMBERS" ]; then
echo "No open Dependabot PRs to process."
exit 0
fi
for PR in $PR_NUMBERS; do
BASE=$(gh pr view "$PR" --repo "$REPO" --json baseRefName -q .baseRefName)
if [ "$BASE" != "$DEV_BRANCH" ]; then
echo "Retargeting PR #$PR ($BASE -> $DEV_BRANCH)"
gh pr edit "$PR" --repo "$REPO" --base "$DEV_BRANCH"
fi
done
# Phase 2: merge one at a time. Each merge can make the remaining npm
# PRs stale, so re-check immediately before merging and rebase stragglers.
for PR in $PR_NUMBERS; do
echo "::group::PR #$PR"
read -r MERGEABLE STATE < <(wait_for_verdict "$PR")
echo " mergeable=$MERGEABLE mergeStateStatus=$STATE"
# BEHIND = clean but needs the latest base; ask Dependabot to rebase
# and skip for now (next run merges it once it is up to date).
if [ "$STATE" = "BEHIND" ]; then
echo " PR #$PR is behind $DEV_BRANCH; asking Dependabot to rebase."
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
echo "::endgroup::"
continue
fi
# DIRTY / CONFLICTING = a real conflict. Try a rebase once (label it so
# we can tell next time); if it is STILL conflicting on a later run
# despite already being labelled, the rebase failed for good - close it
# so Dependabot reopens a fresh PR against the current dev branch.
if [ "$MERGEABLE" = "CONFLICTING" ] || [ "$STATE" = "DIRTY" ]; then
if has_conflict_label "$PR"; then
echo " PR #$PR still conflicts after a prior rebase request; closing so Dependabot reopens it fresh."
gh pr close "$PR" --repo "$REPO" --delete-branch \
--comment "Closing: this PR still conflicts with $DEV_BRANCH after a rebase attempt (its changes are likely already merged). Dependabot will reopen a fresh PR computed against the current $DEV_BRANCH."
else
echo " PR #$PR conflicts with $DEV_BRANCH; requesting a rebase and labelling it."
gh pr edit "$PR" --repo "$REPO" --add-label "$CONFLICT_LABEL"
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
fi
echo "::endgroup::"
continue
fi
# A clean PR that was previously flagged has recovered - drop the label.
if has_conflict_label "$PR"; then
gh pr edit "$PR" --repo "$REPO" --remove-label "$CONFLICT_LABEL" || true
fi
echo " Squash-merging PR #$PR"
if gh pr merge "$PR" --repo "$REPO" --squash --admin; then
echo " Merged PR #$PR"
# Give GitHub a moment to mark the now-stale siblings BEHIND.
sleep 15
else
echo " Could not merge PR #$PR now; it will be retried next run."
fi
echo "::endgroup::"
done
+11 -5
View File
@@ -24,13 +24,18 @@ on:
description: "Build type (Development or Production)"
required: true
type: string
dry_run:
description: "Build the image but do not push to any registry"
required: false
type: boolean
default: false
jobs:
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -52,7 +57,7 @@ jobs:
ALL_TAGS=()
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("release-$VERSION" "latest")
TAGS+=("release-$VERSION" "$VERSION" "latest")
for tag in "${TAGS[@]}"; do
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
@@ -67,6 +72,7 @@ jobs:
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
- name: Login to GHCR
if: ${{ !inputs.dry_run }}
uses: docker/login-action@v4
with:
registry: ghcr.io
@@ -74,7 +80,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Login to Docker Hub (prod only)
if: ${{ inputs.build_type == 'Production' }}
if: ${{ inputs.build_type == 'Production' && !inputs.dry_run }}
uses: docker/login-action@v4
with:
username: bugattiguy527
@@ -85,7 +91,7 @@ jobs:
with:
context: .
file: ./docker/Dockerfile
push: true
push: ${{ !inputs.dry_run }}
platforms: linux/amd64,linux/arm64
tags: ${{ env.ALL_TAGS }}
build-args: |
@@ -96,7 +102,7 @@ jobs:
org.opencontainers.image.created=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=registry,compression=zstd
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
- name: Cleanup Docker
if: always()
+72 -54
View File
@@ -52,12 +52,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -77,7 +77,7 @@ jobs:
run: npm run build && npx electron-builder --win --x64 --ia32
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_nsis
@@ -85,7 +85,7 @@ jobs:
retention-days: 30
- name: Upload Windows ia32 NSIS Installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_nsis
@@ -93,7 +93,7 @@ jobs:
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_msi
@@ -101,7 +101,7 @@ jobs:
retention-days: 30
- name: Upload Windows ia32 MSI Installer
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_msi
@@ -119,7 +119,7 @@ jobs:
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Upload Windows x64 Portable
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_portable
@@ -127,7 +127,7 @@ jobs:
retention-days: 30
- name: Upload Windows ia32 Portable
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_ia32_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_ia32_portable
@@ -142,12 +142,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -190,7 +190,7 @@ jobs:
cd ..
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_appimage
@@ -198,7 +198,7 @@ jobs:
retention-days: 30
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_appimage
@@ -206,7 +206,7 @@ jobs:
retention-days: 30
- name: Upload Linux armv7l AppImage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_appimage
@@ -214,7 +214,7 @@ jobs:
retention-days: 30
- name: Upload Linux x64 DEB
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_deb
@@ -222,7 +222,7 @@ jobs:
retention-days: 30
- name: Upload Linux arm64 DEB
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_deb
@@ -230,7 +230,7 @@ jobs:
retention-days: 30
- name: Upload Linux armv7l DEB
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_deb
@@ -238,7 +238,7 @@ jobs:
retention-days: 30
- name: Upload Linux x64 tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_portable
@@ -246,7 +246,7 @@ jobs:
retention-days: 30
- name: Upload Linux arm64 tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_arm64_portable
@@ -254,7 +254,7 @@ jobs:
retention-days: 30
- name: Upload Linux armv7l tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_armv7l_portable
@@ -326,7 +326,7 @@ jobs:
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
- name: Upload Flatpak bundle
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpak
@@ -334,7 +334,7 @@ jobs:
retention-days: 30
- name: Upload Flatpakref
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_flatpakref
@@ -352,12 +352,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -480,7 +480,7 @@ jobs:
- name: Upload macOS MAS PKG
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: termix_macos_universal_mas
path: release/termix_macos_universal_mas.pkg
@@ -488,7 +488,7 @@ jobs:
if-no-files-found: warn
- name: Upload macOS Universal DMG
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_universal_dmg
@@ -496,7 +496,7 @@ jobs:
retention-days: 30
- name: Upload macOS x64 DMG
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_x64_dmg
@@ -504,7 +504,7 @@ jobs:
retention-days: 30
- name: Upload macOS arm64 DMG
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_arm64_dmg
@@ -542,7 +542,7 @@ jobs:
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
- name: Upload Homebrew Cask as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'file'
with:
name: termix_macos_homebrew_cask
@@ -578,7 +578,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -669,7 +669,7 @@ jobs:
}
- name: Upload Chocolatey package as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: chocolatey-package
path: choco-build/*.nupkg
@@ -684,7 +684,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -756,7 +756,7 @@ jobs:
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
- name: Upload Flatpak submission as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: flatpak-submission
path: flatpak-submission/*
@@ -771,7 +771,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -825,7 +825,7 @@ jobs:
ruby -c homebrew-submission/Casks/t/termix.rb
- name: Upload Homebrew submission as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: homebrew-submission
path: homebrew-submission/*
@@ -840,7 +840,7 @@ jobs:
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: artifacts
@@ -872,7 +872,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
submit-to-testflight:
submit-to-app-store:
runs-on: blacksmith-6vcpu-macos-latest
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
needs: []
@@ -881,12 +881,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -943,9 +943,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_OPTIONS: --max-old-space-size=4096
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
BUILD_VERSION="${{ github.run_number }}"
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
- name: Check for App Store Connect API credentials
@@ -956,36 +954,56 @@ jobs:
fi
- name: Setup Ruby for Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true'
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: false
- name: Install Fastlane
if: steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
gem install fastlane -N
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: gem install fastlane -N
- name: Deploy to App Store Connect (TestFlight)
if: steps.check_asc_creds.outputs.has_credentials == 'true'
- name: Upload and submit to Mac App Store
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
if [ -z "$PKG_FILE" ]; then
echo "PKG file not found, exiting."
echo "PKG file not found in release/; aborting."
exit 1
fi
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
VERSION=$(node -p "require('./package.json').version")
xcrun altool --upload-app -f "$PKG_FILE" \
--type macos \
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
continue-on-error: true
# Write API key JSON that Fastlane deliver expects
mkdir -p /tmp/asc_keys
KEY_P8_PATH="/tmp/asc_keys/AuthKey_${APPLE_KEY_ID}.p8"
API_KEY_JSON="/tmp/asc_keys/api_key.json"
echo "$APPLE_KEY_CONTENT" | base64 --decode > "$KEY_P8_PATH"
printf '{\n "key_id": "%s",\n "issuer_id": "%s",\n "key": "%s",\n "in_house": false\n}\n' \
"$APPLE_KEY_ID" \
"$APPLE_ISSUER_ID" \
"$(tr -d '\n' < "$KEY_P8_PATH")" \
> "$API_KEY_JSON"
fastlane deliver \
--pkg "$PKG_FILE" \
--api_key_path "$API_KEY_JSON" \
--app_version "$VERSION" \
--skip_metadata true \
--skip_screenshots true \
--submit_for_review true \
--automatic_release true \
--force true
- name: Clean up keychains
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -rf /tmp/asc_keys
+3 -3
View File
@@ -10,10 +10,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -25,7 +25,7 @@ jobs:
run: npm run generate:openapi
- name: Upload OpenAPI artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: openapi-spec
path: openapi.json
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: "npm"
+395 -14
View File
@@ -2,6 +2,17 @@ name: Release
on:
workflow_dispatch:
inputs:
mode:
description: "Release mode"
required: true
default: Everything
type: choice
options:
- Everything
- Overwrite release
- Dry run
- Skip submit
permissions:
contents: write
@@ -13,20 +24,27 @@ jobs:
version: ${{ steps.info.outputs.version }}
release_tag: ${{ steps.info.outputs.release_tag }}
mobile_version: ${{ steps.info.outputs.mobile_version }}
dev_branch: ${{ steps.info.outputs.dev_branch }}
steps:
- name: Guard branch
if: github.ref != 'refs/heads/main'
- name: Guard branch (release modes)
if: ${{ inputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
run: |
echo "Releases must be run from main (got ${{ github.ref }})."
echo "This mode must be run from a dev branch (got ${{ github.ref }})."
exit 1
- name: Guard branch (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
run: |
echo "Overwrite release must be run from main (got ${{ github.ref }})."
exit 1
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
@@ -35,7 +53,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION=$(node -p "require('./package.json').version")
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
DEV_BRANCH=""
VERSION=$(node -p "require('./package.json').version")
else
DEV_BRANCH="${GITHUB_REF#refs/heads/}"
VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
fi
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"
@@ -54,21 +79,217 @@ jobs:
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
--notes RELEASE_NOTES.md > /dev/null
docker:
normalize:
needs: [prep]
if: ${{ inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
uses: actions/checkout@v5
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Lint and format
run: |
npm run lint:fix || true
npm run format
- name: Run unit tests
run: npm run test
- name: Sync version
run: node scripts/sync-version.cjs --version "${{ needs.prep.outputs.version }}"
- name: Commit changes to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
if git diff --quiet; then
echo "No lint/format/version changes to commit."
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:"
git --no-pager diff --stat
exit 0
fi
git add -A
git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
crowdin:
needs: [prep, normalize]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout dev branch
uses: actions/checkout@v5
with:
ref: ${{ needs.prep.outputs.dev_branch }}
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Clean up stale Crowdin git integration branch
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
if [ -n "$PR_NUMBER" ]; then
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
fi
git push origin --delete i18n_translate || true
- name: Upload sources to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Machine pre-translate untranslated strings
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
run: node scripts/crowdin-pretranslate.cjs
- name: Download translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
create_pull_request: false
push_translations: false
token: ${{ secrets.CROWDIN_API_KEY }}
project_id: "858252"
env:
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
- name: Commit translations to dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git add src/ui/locales/translated
if git diff --cached --quiet; then
echo "No translation changes to commit."
exit 0
fi
git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
build_ref: ${{ steps.merge.outputs.build_ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Generate PR body
id: body
run: |
node scripts/generate-release-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
--notes RELEASE_NOTES.md > PR_BODY.md
- name: Open and squash-merge PR to main
id: merge
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
TITLE="release-${{ needs.prep.outputs.version }}"
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
echo "OVERWRITE: no merge; building from main as-is."
echo "build_ref=main" >> "$GITHUB_OUTPUT"
echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main."
echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main."
echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
echo "main_sha=" >> "$GITHUB_OUTPUT"
exit 0
fi
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$DEV_BRANCH" --base main --state open --json number -q '.[0].number' || true)
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr create --repo ${{ github.repository }} \
--base main --head "$DEV_BRANCH" \
--title "$TITLE" --body-file PR_BODY.md \
| grep -oE '[0-9]+$')
fi
gh pr merge "$PR_NUMBER" --repo ${{ github.repository }} --squash --admin
STATE=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json state -q .state)
if [ "$STATE" != "MERGED" ]; then
echo "PR #$PR_NUMBER did not merge (state: $STATE)."
exit 1
fi
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
echo "build_ref=main" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, merge-to-main]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prep.outputs.version }}
build_type: Production
dry_run: ${{ inputs.mode == 'Dry run' }}
secrets: inherit
create-release:
needs: [prep, docker]
needs: [prep, merge-to-main, docker]
if: ${{ inputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout repository
- name: Checkout main
uses: actions/checkout@v5
with:
ref: main
fetch-depth: 1
- name: Setup Node.js
@@ -76,7 +297,23 @@ jobs:
with:
node-version-file: ".nvmrc"
- name: Clear existing release artifacts (overwrite mode)
if: ${{ inputs.mode == 'Overwrite release' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ needs.prep.outputs.release_tag }}"
if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
echo "Overwrite release: no existing release $TAG to overwrite."
exit 1
fi
echo "Clearing existing assets from $TAG..."
gh release view "$TAG" --repo ${{ github.repository }} --json assets -q '.assets[].name' | while read -r ASSET; do
[ -n "$ASSET" ] && gh release delete-asset "$TAG" "$ASSET" --repo ${{ github.repository }} --yes || true
done
- name: Generate release body
if: ${{ inputs.mode != 'Overwrite release' }}
run: |
node scripts/generate-release-body.cjs \
--version "${{ needs.prep.outputs.version }}" \
@@ -84,6 +321,7 @@ jobs:
--notes RELEASE_NOTES.md > RELEASE_BODY.md
- name: Create or update GitHub release
if: ${{ inputs.mode != 'Overwrite release' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -92,20 +330,22 @@ jobs:
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
else
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ github.sha }}"
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}"
fi
electron-release:
needs: [prep, create-release]
needs: [prep, merge-to-main, create-release]
if: ${{ always() && needs.merge-to-main.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: release
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
secrets: inherit
electron-submit:
needs: [electron-release]
needs: [prep, electron-release]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
@@ -114,12 +354,13 @@ jobs:
cask-commit-back:
needs: [prep, electron-release]
if: ${{ inputs.mode != 'Dry run' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 1
@@ -145,6 +386,146 @@ jobs:
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git pull --rebase origin main
git add Casks/termix.rb
git commit -m "chore: bump Homebrew cask to $VERSION"
git push origin HEAD:main
docs:
needs: [prep, merge-to-main]
if: ${{ always() && needs.merge-to-main.result == 'success' && inputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout Termix
uses: actions/checkout@v5
with:
ref: ${{ needs.merge-to-main.outputs.build_ref }}
fetch-depth: 1
path: termix
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: "termix/.nvmrc"
cache: "npm"
cache-dependency-path: termix/package-lock.json
- name: Generate OpenAPI spec
working-directory: termix
run: |
npm ci
npm run generate:openapi
- name: Checkout Docs repository
uses: actions/checkout@v5
with:
repository: Termix-SSH/Docs
ref: main
fetch-depth: 0
token: ${{ secrets.GHCR_TOKEN }}
path: docs-repo
- name: Create docs release branch
working-directory: docs-repo
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
- name: Overwrite OpenAPI spec and regenerate API docs
working-directory: docs-repo
run: |
cp ../termix/openapi.json static/openapi.json
npm ci
npm run docusaurus gen-api-docs termix
- name: Commit and push docs branch
working-directory: docs-repo
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
if git diff --quiet; then
echo "No docs changes to commit."
exit 0
fi
if [ "${{ inputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit and push the following docs changes to dev-${{ needs.prep.outputs.version }}:"
git --no-pager diff --stat
exit 0
fi
git add -A
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
git push origin "dev-${{ needs.prep.outputs.version }}"
- name: Open and squash-merge docs PR
if: ${{ inputs.mode != 'Dry run' }}
working-directory: docs-repo
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
BRANCH="dev-${{ needs.prep.outputs.version }}"
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
echo "No docs branch pushed (nothing changed); skipping PR."
exit 0
fi
PR_NUMBER=$(gh pr list --repo Termix-SSH/Docs --head "$BRANCH" --base main --state open --json number -q '.[0].number' || true)
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr create --repo Termix-SSH/Docs \
--base main --head "$BRANCH" \
--title "release-${{ needs.prep.outputs.version }}" \
--body "API docs for ${{ needs.prep.outputs.version }}" \
| grep -oE '[0-9]+$')
fi
gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin
publish-youtube:
needs: [prep, electron-release]
if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout main
uses: actions/checkout@v5
with:
ref: main
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- name: Set release video to public
env:
YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
run: node scripts/publish-youtube.cjs
cleanup:
needs:
[
prep,
merge-to-main,
electron-release,
cask-commit-back,
docs,
publish-youtube,
]
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' && needs.cask-commit-back.result == 'success' && needs.docs.result == 'success' && needs.publish-youtube.result == 'success' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Delete dev branch in Termix
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/${{ github.repository }}.git \
--delete "${{ needs.prep.outputs.dev_branch }}" || true
- name: Delete dev branch in Docs
env:
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
run: |
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/Termix-SSH/Docs.git \
--delete "dev-${{ needs.prep.outputs.version }}" || true
+18 -9
View File
@@ -94,59 +94,67 @@ Start, stop, pause, remove containers. View container stats. Control container u
<td width="50%" valign="top">
**SSH Host Manager:**
Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys.
Save, organize, and manage your SSH connections with tags and folders (folder customization and nested folder support), and easily save reusable login info while being able to automate the deployment of SSH keys.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Server Stats:**
View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers.
**Host Metrics:**
View CPU, memory, disk usage, network, uptime, system information, firewall, port monitor, log viewer, users/permissions, certificates, and many more which work on most Linux based servers.
</td>
<td width="50%" valign="top">
**User Authentication:**
Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
Secure user management with admin controls and OIDC/LDAP/SSO (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Tailscale Integration:**
List devices from your tailnet to quickly add them as hosts, and connect using Tailscale SSH as an authentication method, letting your tailnet ACLs handle authorization without storing credentials.
</td>
<td width="50%" valign="top">
**RBAC:**
Create roles and share hosts across users/roles.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Database Encryption:**
Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Network Graph:**
Customize your Dashboard to visualize your homelab based off your SSH connections with status support.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**SSH Tools:**
Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Persistent Tabs:**
SSH sessions and tabs stay open across devices/refreshes if enabled in user profile.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Languages:**
@@ -170,7 +178,8 @@ Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/tr
- **Command History** - Auto-complete and view previously ran SSH commands
- **Quick Connect** - Connect to a server without having to save the connection data
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
- **Proxmox Integration** - Auto-add hosts into Termix from your Proxmox instance
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal logging, etc.
</details>
+36 -38
View File
@@ -1,55 +1,53 @@
<!-- SUMMARY -->
Bug fixes and new features including host-to-host file transfer, OIDC improvements, UI/UX updates, and numerous stability and security patches.
Bug fixes and new features, including Proxmox integration, SSO/OIDC redesign, revamped host metrics, tmux session management, and numerous UI and stability improvements.
<!-- /SUMMARY -->
<!-- YOUTUBE -->
https://youtu.be/At8iDk6-Q_s
https://youtu.be/ImwAbm4hW-k
<!-- /YOUTUBE -->
<!-- UPDATE_LOG -->
- Added in-line buttons on host name row when using click to expand hosts in hosts list
- Expose admin_group via OIDC_ADMIN_GROUP env var
- Sync appearance preferences
- Support native OIDC callbacks
- Add portal Desktop DBUS permission for Flatpak URL opening
- Restore host password copy
- Support for single-host direct tunnels (ssh -L)
- Host to host file transfer in file manager
- Show ip/username without having to hover over hosts
- Updated credential list UI to match UI/UX of host list
- Restore rename host folder UI
- Improved terminal syntax highlighting with more customizability and reliability (toggle setting moved from user profile to host editor)
- Tmux session monitor/management
- Tailscale SSH authentication and device listing support
- SSO/OIDC redesign (multiple OIDC providers, LDAP, Google, GitHub support)
- Terminal session logging
- Customize side rail tab visibility
- Admin audit log
- Added `x.x.x` version tag alongside `release-x.x.x` for Docker
- OIDC custom group claim support
- Renamed server stats to host metrics
- Fully revamped host metrics page with new cards and dashboard like organizing system (services, process inspector, log viewer, cron jobs, packages, ssl cert management, firewall, user/permissions, health checks, disk breakdown, timers, and top by memory)
- Proxmox guest discovery and import integration
- Moved ssh host config outside of top tab bar and into new tab bar visible on SSH tab
- Improved folder management (nested folders, folder icons, folder colors, better folder selection, etc.)
- Storage preference to user profile settings (store/load toggles locally or in the DB)
- Sort/filter functions to credential list (copy of host list)
<!-- /UPDATE_LOG -->
<!-- BUG_FIXES -->
- Several security vulnerabilities
- Allow navigating away from split-view to non-pane tabs
- Restore SSH keepalive internal to use 30s to prevent random disconnects
- Apply guacamole-lite protocol patch in Docker builds
- Show correct icons for network interface types
- Resolve sudo password for shared host users
- Use jump hosts for online status check and metric collection
- Broaden sudo prompt detection for newer distros
- Recalculate terminal layout after web fonts load
- Improve terminal cwd detection and initial directory command
- Decode base64 file content as UTF-8 in file manager
- Normalize lazy import default export for iOS compatibility
- Prevent RDP display from snapping back after container resize
- Removed unused code and fixed PR checks and lint warnings
- Send name instruction for protocol >= 1.3.0 (guacd 1.5.0/1.1.0 errors)
- Wire up OIDC to password link dialog with submit/visibility
- Pass through command completion
- Resolve terminal jump hosts server-side
- Auto allow SSL certs for private network hosts
- Removed deprecated host management button from command palette
- Command palette opening wrong protocol
- Export/import failing for ssh key hosts
- Docker ssh2 native crypto not compiling in Docker
- Persisted terminal tabs attempt SSH on RDP hosts afater migration
- Credentials not appearing in host manager until refresh
- SFTP jump-host fallback from host data
- Guacd password incorrectly passed for app view
- Admin page failing to load admin information
- Disabled hardware acceleration on Windows to prevent startup crash
- Dashboard total credentials stuck at 0
- Host username ignored when credential attached
- Clone host cannot switch auth method
- File manager context menu off-screen
- File delete affecting inactive tabs
- Silent delete failure on Windows hosts
- iPad host tab does nothing
- Docker console terminal background using incorrect colors
- Reliable OIDC group syncing for admin roles
- Guacd connections using incorrect screen height
- 2FA failing to disable
- Hostname fill entire column and truncate at proper spot in dashboard
- Make credentials start collapsed
- Incorrect JSDoc comments
<!-- /BUG_FIXES -->
+3
View File
@@ -1,3 +1,6 @@
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"
files:
- source: /src/ui/locales/en.json
translation: /src/ui/locales/translated/%locale_with_underscore%.json
+57 -9
View File
@@ -58,6 +58,8 @@ http {
listen ${SSL_PORT} ssl;
server_name _;
absolute_redirect off;
ssl_certificate ${SSL_CERT_PATH};
ssl_certificate_key ${SSL_KEY_PATH};
@@ -187,6 +189,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -312,6 +323,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /tailscale/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/websocket/ {
proxy_pass http://127.0.0.1:30002/;
proxy_http_version 1.1;
@@ -522,6 +551,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/host-metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
location ~ ^/global-settings(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
@@ -549,15 +591,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
@@ -583,6 +616,21 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
proxy_http_version 1.1;
+69 -9
View File
@@ -51,6 +51,8 @@ http {
listen ${PORT};
server_name _;
absolute_redirect off;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
@@ -167,6 +169,18 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/proxmox(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -176,6 +190,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/audit-logs(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -301,6 +324,24 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /session_logs/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /tailscale/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ssh/websocket/ {
proxy_pass http://127.0.0.1:30002/;
proxy_http_version 1.1;
@@ -511,6 +552,19 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/host-metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
location ~ ^/global-settings(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
@@ -538,15 +592,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/dashboard/preferences(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /docker/console/ {
proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
@@ -572,6 +617,21 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
# --- tmux-monitor begin ---
location ~ ^/tmux_monitor(/.*)?$ {
proxy_pass http://127.0.0.1:30010;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# --- tmux-monitor end ---
location ~ ^/docker(/.*)?$ {
proxy_pass http://127.0.0.1:30007;
proxy_http_version 1.1;
+4
View File
@@ -478,6 +478,10 @@ if (process.platform === "linux") {
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
}
if (process.platform === "win32") {
app.disableHardwareAcceleration();
}
if (isInsecureModeEnabled()) {
logToFile(
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.",
+3244 -463
View File
File diff suppressed because it is too large Load Diff
+28 -26
View File
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
"version": "2.3.2",
"version": "2.4.0",
"description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -41,7 +41,8 @@
"build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never"
},
"dependencies": {
"axios": "^1.15.2",
"@types/ldapjs": "^3.0.6",
"axios": "^1.17.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
"body-parser": "^2.2.2",
@@ -53,9 +54,10 @@
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.2",
"js-yaml": "^4.1.1",
"js-yaml": "^4.2.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
"motion": "^12.38.0",
"multer": "^2.1.1",
"nanoid": "^5.1.9",
@@ -63,39 +65,39 @@
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^7.0.0",
"undici": "^8.4.0",
"ws": "^8.20.0"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.7.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.1",
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
"@electron/notarize": "^3.1.1",
"@electron/rebuild": "^4.0.4",
"@eslint/js": "^9.0.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -109,9 +111,9 @@
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.5",
@@ -141,16 +143,16 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
"i18next": "^26.0.8",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.5",
"lint-staged": "^17.0.7",
"lucide-react": "^1.11.0",
"prettier": "3.8.3",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.5",
"react-dom": "^19.2.7",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.73.1",
"react-i18next": "^17.0.4",
@@ -168,7 +170,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.13",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
"vitest": "^4.1.8"
},
+6 -5
View File
@@ -94,21 +94,21 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
<td width="50%" valign="top">
**مدير مضيفات SSH:**
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات (مع دعم تخصيص المجلدات والمجلدات المتداخلة)، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**إحصائيات الخادم:**
عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux.
**مقاييس المضيف:**
عرض استخدام المعالج والذاكرة والقرص والشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ وعارض السجلات والمستخدمين/الصلاحيات والشهادات وغيرها الكثير، تعمل على معظم الخوادم المبنية على Linux.
</td>
<td width="50%" valign="top">
**مصادقة المستخدمين:**
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC/LDAP/SSO (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. عرض سجل تدقيق لجميع إجراءات المستخدمين.
</td>
</tr>
@@ -170,7 +170,8 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ.
- **تكامل Proxmox** - إضافة المضيفات تلقائياً إلى Termix من نسخة Proxmox الخاصة بك
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، تسجيل الطرفية، إلخ.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
<td width="50%" valign="top">
**SSH 主机管理器:**
通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
通过标签和文件夹(支持文件夹自定义和嵌套文件夹)保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**服务器统计:**
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙端口监控。
**主机指标:**
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况网络、运行时间、系统信息、防火墙端口监控、日志查看器、用户/权限、证书等更多信息
</td>
<td width="50%" valign="top">
**用户认证:**
安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
安全的用户管理,具有管理员控制、OIDC/LDAP/SSO(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。查看所有用户操作的审计日志。
</td>
</tr>
@@ -170,7 +170,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **命令历史** - 自动完成并查看之前运行过的 SSH 命令
- **快速连接** - 无需保存连接数据即可连接到服务器
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等
- **Proxmox 集成** - 从您的 Proxmox 实例自动将主机添加到 Termix
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击、终端日志记录等
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen
<td width="50%" valign="top">
**SSH-Host-Manager:**
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern (Ordneranpassung und verschachtelte Ordner werden unterstutzt) und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Serverstatistiken:**
CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen.
**Host-Metriken:**
CPU-, Arbeitsspeicher- und Festplattenauslastung, Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor, Log-Viewer, Benutzer/Berechtigungen, Zertifikate und vieles mehr auf den meisten Linux-basierten Servern anzeigen.
</td>
<td width="50%" valign="top">
**Benutzerauthentifizierung:**
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen.
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzung (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. Audit-Protokoll aller Benutzeraktionen anzeigen.
</td>
</tr>
@@ -170,7 +170,8 @@ Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://
- **Befehlsverlauf** - Autovervollstandigung und Anzeige zuvor ausgefuhrter SSH-Befehle
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu mussen
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw.
- **Proxmox-Integration** - Automatisches Hinzufugen von Hosts zu Termix aus Ihrer Proxmox-Instanz
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking, Terminal-Protokollierung usw.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores.
<td width="50%" valign="top">
**Gestor de Hosts SSH:**
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas (con personalizacion de carpetas y soporte de carpetas anidadas), y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Estadisticas del Servidor:**
Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos en la mayoria de los servidores basados en Linux.
**Metricas del Host:**
Vea el uso de CPU, memoria y disco, red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos, visor de registros, usuarios/permisos, certificados y muchos mas en la mayoria de los servidores basados en Linux.
</td>
<td width="50%" valign="top">
**Autenticacion de Usuarios:**
Gestion segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si.
Gestion segura de usuarios con controles de administrador y soporte para OIDC/LDAP/SSO (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. Vea el registro de auditoria de las acciones de todos los usuarios.
</td>
</tr>
@@ -170,7 +170,8 @@ Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](http
- **Historial de Comandos** - Autocompletado y visualizacion de comandos SSH ejecutados anteriormente
- **Conexion Rapida** - Conectese a un servidor sin necesidad de guardar los datos de conexion
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rapidamente a las conexiones SSH con su teclado
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
- **Integracion con Proxmox** - Agregue automaticamente hosts a Termix desde su instancia de Proxmox
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les stat
<td width="50%" valign="top">
**Gestionnaire d'hotes SSH:**
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers (personnalisation des dossiers et prise en charge des dossiers imbriques), et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Statistiques serveur:**
Visualisez l'utilisation du CPU, de la memoire et du disque ainsi que le reseau, le temps de fonctionnement, les informations systeme, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux.
**Metriques d'hote:**
Visualisez l'utilisation du CPU, de la memoire, du disque, le reseau, le temps de fonctionnement, les informations systeme, le pare-feu, le moniteur de ports, le visualiseur de journaux, les utilisateurs/permissions, les certificats et bien plus encore sur la plupart des serveurs Linux.
</td>
<td width="50%" valign="top">
**Authentification des utilisateurs:**
Gestion securisee des utilisateurs avec controles administrateur et support OIDC (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble.
Gestion securisee des utilisateurs avec controles administrateur et support OIDC/LDAP/SSO (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. Consultez le journal d'audit des actions de tous les utilisateurs.
</td>
</tr>
@@ -170,7 +170,8 @@ Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.sit
- **Historique des commandes** - Auto-completion et consultation des commandes SSH precedemment executees
- **Connexion rapide** - Connectez-vous a un serveur sans avoir a sauvegarder les donnees de connexion
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour acceder rapidement aux connexions SSH avec votre clavier
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
- **Integration Proxmox** - Ajoutez automatiquement des hotes dans Termix depuis votre instance Proxmox
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, journalisation du terminal, etc.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
<td width="50%" valign="top">
**SSH होस्ट मैनेजर:**
टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
टैग और फ़ोल्डर (फ़ोल्डर कस्टमाइज़ेशन और नेस्टेड फ़ोल्डर सपोर्ट के साथ) के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
</td>
</tr>
<tr>
<td width="50%" valign="top">
**सर्वर आँकड़े:**
अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें।
**होस्ट मेट्रिक्स:**
अधिकांश Linux आधारित सर्वर पर CPU, मेमोरी, डिस्क उपयोग, नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर, लॉग व्यूअर, उपयोगकर्ता/अनुमतियाँ, सर्टिफ़िकेट और भी बहुत कुछ देखें।
</td>
<td width="50%" valign="top">
**उपयोगकर्ता प्रमाणीकरण:**
व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
व्यवस्थापक नियंत्रण और OIDC/LDAP/SSO (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। सभी उपयोगकर्ताओं की कार्रवाइयों का ऑडिट लॉग देखें।
</td>
</tr>
@@ -170,7 +170,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट
- **Proxmox एकीकरण** - अपने Proxmox इंस्टेंस से Termix में होस्ट स्वचालित रूप से जोड़ें
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग, टर्मिनल लॉगिंग आदि का सपोर्ट
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei c
<td width="50%" valign="top">
**Gestore Host SSH:**
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle (con personalizzazione delle cartelle e supporto per cartelle annidate), salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Statistiche Server:**
Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux.
**Metriche Host:**
Visualizza l'utilizzo di CPU, memoria, disco, rete, uptime, informazioni di sistema, firewall, monitoraggio porte, visualizzatore di log, utenti/permessi, certificati e molto altro sulla maggior parte dei server basati su Linux.
</td>
<td width="50%" valign="top">
**Autenticazione Utente:**
Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. Visualizza il log di controllo delle azioni di tutti gli utenti.
</td>
</tr>
@@ -170,7 +170,8 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc.
- **Integrazione Proxmox** - Aggiungi automaticamente host a Termix dalla tua istanza Proxmox
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registrazione terminale, ecc.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
<td width="50%" valign="top">
**SSHホストマネージャー:**
タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
タグやフォルダ(フォルダのカスタマイズとネストフォルダ対応)でSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
</td>
</tr>
<tr>
<td width="50%" valign="top">
**サーバー統計:**
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。
**ホストメトリクス:**
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニター、ログビューア、ユーザー/権限、証明書など、さらに多くの情報を表示できます。
</td>
<td width="50%" valign="top">
**ユーザー認証:**
管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
管理者コントロールとOIDC/LDAP/SSO(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。すべてのユーザー操作の監査ログを表示できます。
</td>
</tr>
@@ -170,7 +170,8 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です
- **クイック接続** - 接続データを保存せずにサーバーに接続できます
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています
- **Proxmox統合** - Proxmoxインスタンスからホストを自動的にTermixに追加できます
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)、ターミナルログ記録などに対応しています
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
<td width="50%" valign="top">
**SSH 호스트 관리자:**
태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
태그와 폴더(폴더 사용자 지정 및 중첩 폴더 지원)로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**서버 통계:**
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시.
**호스트 메트릭:**
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량, 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터, 로그 뷰어, 사용자/권한, 인증서 등 다양한 정보를 표시.
</td>
<td width="50%" valign="top">
**사용자 인증:**
관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
관리자 제어와 OIDC/LDAP/SSO(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. 모든 사용자 작업의 감사 로그 조회.
</td>
</tr>
@@ -170,7 +170,8 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원
- **Proxmox 통합** - Proxmox 인스턴스에서 Termix로 호스트를 자동 추가
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹, 터미널 로깅 등 지원
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres.
<td width="50%" valign="top">
**Gerenciador de Hosts SSH:**
Salve, organize e gerencie suas conexoes SSH com tags e pastas, e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
Salve, organize e gerencie suas conexoes SSH com tags e pastas (com personalizacao de pastas e suporte a pastas aninhadas), e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Estatisticas do Servidor:**
Visualize o uso de CPU, memoria e disco junto com rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux.
**Metricas do Host:**
Visualize o uso de CPU, memoria e disco, rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas, visualizador de logs, usuarios/permissoes, certificados e muito mais na maioria dos servidores baseados em Linux.
</td>
<td width="50%" valign="top">
**Autenticacao de Usuarios:**
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si.
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC/LDAP/SSO (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. Visualize o log de auditoria de todas as acoes dos usuarios.
</td>
</tr>
@@ -170,7 +170,8 @@ Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](htt
- **Historico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
- **Conexao Rapida** - Conecte-se a um servidor sem precisar salvar os dados de conexao
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexoes SSH com seu teclado
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
- **Integracao com Proxmox** - Adicione automaticamente hosts ao Termix a partir da sua instancia Proxmox
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Termix - это платформа для управления серверам
<td width="50%" valign="top">
**Менеджер SSH-хостов:**
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок (с настройкой папок и поддержкой вложенных папок), с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Статистика сервера:**
Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux.
**Метрики хоста:**
Просмотр использования CPU, памяти и диска, сети, времени работы, информации о системе, файрвола, монитора портов, просмотрщика логов, пользователей/прав доступа, сертификатов и многого другого на большинстве серверов на базе Linux.
</td>
<td width="50%" valign="top">
**Аутентификация пользователей:**
Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
Безопасное управление пользователями с административным контролем и поддержкой OIDC/LDAP/SSO (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. Просмотр журнала аудита действий всех пользователей.
</td>
</tr>
@@ -170,7 +170,8 @@ SSH-сессии и вкладки остаются открытыми на вс
- **История команд** - Автодополнение и просмотр ранее выполненных SSH-команд
- **Быстрое подключение** - Подключение к серверу без необходимости сохранения данных подключения
- **Командная палитра** - Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др.
- **Интеграция с Proxmox** - Автоматическое добавление хостов в Termix из вашего экземпляра Proxmox
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, логирования терминала и др.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistikle
<td width="50%" valign="top">
**SSH Ana Bilgisayar Yoneticisi:**
SSH baglantilarinizi etiketler ve klasorlerle kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
SSH baglantilarinizi etiketler ve klasorlerle (klasor ozellestirme ve ic ice klasor destegi ile) kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Sunucu Istatistikleri:**
Cogu Linux tabanli sunucularda CPU, bellek ve disk kullanimini ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme ile birlikte goruntuleyin.
**Ana Bilgisayar Metrikleri:**
Cogu Linux tabanli sunucularda CPU, bellek, disk kullanimi, ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme, gunluk goruntuleyici, kullanicilar/izinler, sertifikalar ve daha fazlasini goruntuleyin.
</td>
<td width="50%" valign="top">
**Kullanici Kimlik Dogrulama:**
Yonetici kontrolleri, OIDC (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin.
Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. Tum kullanicilarin islemlerinin denetim gunlugunu goruntuleyin.
</td>
</tr>
@@ -170,7 +170,8 @@ Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/transla
- **Komut Gecmisi** - Daha once calistirilan SSH komutlarini otomatik tamamlayin ve goruntuleyin
- **Hizli Baglanti** - Baglanti verilerini kaydetmeden bir sunucuya baglanin
- **Komut Paleti** - Sol shift tusuna iki kez basarak SSH baglantilariniza klavyenizle hizlica erisin
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler.
- **Proxmox Entegrasyonu** - Proxmox ornekinizden Termix'e otomatik olarak ana bilgisayar ekleyin
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal gunlukleme vb. destekler.
</details>
+6 -5
View File
@@ -94,21 +94,21 @@ Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien con
<td width="50%" valign="top">
**Trinh Quan Ly May Chu SSH:**
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc, de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc (ho tro tuy chinh thu muc va thu muc long nhau), de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
</td>
</tr>
<tr>
<td width="50%" valign="top">
**Thong Ke May Chu:**
Xem muc su dung CPU, bo nho va o dia cung voi mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong tren hau het cac may chu chay Linux.
**Chi So May Chu:**
Xem muc su dung CPU, bo nho, o dia, mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong, trinh xem nhat ky, nguoi dung/quyen, chung chi va nhieu hon nua tren hau het cac may chu chay Linux.
</td>
<td width="50%" valign="top">
**Xac Thuc Nguoi Dung:**
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau.
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. Xem nhat ky kiem toan cac hanh dong cua tat ca nguoi dung.
</td>
</tr>
@@ -170,7 +170,8 @@ Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.term
- **Lich Su Lenh** - Tu dong hoan thanh va xem cac lenh SSH da chay truoc do
- **Ket Noi Nhanh** - Ket noi den may chu ma khong can luu du lieu ket noi
- **Bang Lenh** - Nhan dup phim shift trai de truy cap nhanh cac ket noi SSH bang ban phim
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v.
- **Tich Hop Proxmox** - Tu dong them may chu vao Termix tu instance Proxmox cua ban
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ghi nhat ky terminal, v.v.
</details>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 534 KiB

+121
View File
@@ -0,0 +1,121 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
async function resolveFileId(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const match = files.find(
(f) => f.name === SOURCE_FILE || f.path === `/${SOURCE_FILE}`,
);
if (!match)
throw new Error(`source file "${SOURCE_FILE}" not found in project`);
return match.id;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (let i = 0; i < 120; i++) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error("pre-translation timed out after 10 minutes");
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileId = await resolveFileId(projectId);
console.log(
`Pre-translating project ${projectId}, file ${fileId}, ${targetLanguageIds.length} languages via MT engine ${ENGINE_ID}`,
);
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{
languageIds: targetLanguageIds,
fileIds: [fileId],
method: "mt",
engineId: ENGINE_ID,
scope: "untranslated",
},
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(`Pre-translation finished (${result.progress}%)`);
}
module.exports = {
resolveProjectId,
resolveFileId,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
+43
View File
@@ -0,0 +1,43 @@
const { parseDevBranch } = require("./parse-dev-branch.cjs");
function compareSemver(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] !== pb[i]) return pa[i] - pb[i];
}
return 0;
}
function latestDevBranch(refs) {
const versions = [];
for (const ref of refs) {
const name = ref.replace(/^refs\/heads\//, "").trim();
if (!name) continue;
try {
versions.push({ name, version: parseDevBranch(name) });
} catch {
// not a dev-X.Y.Z branch, skip it
}
}
if (versions.length === 0) {
throw new Error("no dev-X.Y.Z branches found");
}
versions.sort((a, b) => compareSemver(a.version, b.version));
return versions[versions.length - 1].name;
}
module.exports = { latestDevBranch };
if (require.main === module) {
const input = require("fs").readFileSync(0, "utf8");
const refs = input.split("\n").filter(Boolean);
try {
process.stdout.write(latestDevBranch(refs) + "\n");
} catch (err) {
console.error(`latest-dev-branch: ${err.message}`);
process.exit(1);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { latestDevBranch } = require("./latest-dev-branch.cjs");
describe("latestDevBranch", () => {
it("picks the highest semver dev branch", () => {
expect(latestDevBranch(["dev-2.3.9", "dev-2.4.0", "dev-1.11.2"])).toBe(
"dev-2.4.0",
);
});
it("compares numerically, not lexically", () => {
expect(latestDevBranch(["dev-2.10.0", "dev-2.9.0"])).toBe("dev-2.10.0");
});
it("ignores main and non-dev branches", () => {
expect(latestDevBranch(["main", "feature/x", "dev-1.0.0"])).toBe(
"dev-1.0.0",
);
});
it("strips refs/heads/ prefixes", () => {
expect(latestDevBranch(["refs/heads/dev-3.0.1"])).toBe("dev-3.0.1");
});
it("throws when there are no dev branches", () => {
expect(() => latestDevBranch(["main", "feature/x"])).toThrow(
/no dev-X\.Y\.Z branches/,
);
});
});
+36
View File
@@ -0,0 +1,36 @@
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function parseDevBranch(ref) {
if (!ref || typeof ref !== "string") {
throw new Error("a branch ref is required");
}
const name = ref.replace(/^refs\/heads\//, "").trim();
if (!name.startsWith("dev-")) {
throw new Error(
`release must run from a dev branch, got "${name}" (expected dev-X.Y.Z)`,
);
}
const version = name.slice("dev-".length);
if (!SEMVER.test(version)) {
throw new Error(
`branch "${name}" does not contain a valid semver version (got "${version}")`,
);
}
return version;
}
module.exports = { parseDevBranch };
if (require.main === module) {
const ref = process.argv[2] || process.env.GITHUB_REF;
try {
process.stdout.write(parseDevBranch(ref) + "\n");
} catch (err) {
console.error(`parse-dev-branch: ${err.message}`);
process.exit(1);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { parseDevBranch } = require("./parse-dev-branch.cjs");
describe("parseDevBranch", () => {
it("extracts the version from a dev branch name", () => {
expect(parseDevBranch("dev-2.4.0")).toBe("2.4.0");
});
it("strips a refs/heads/ prefix", () => {
expect(parseDevBranch("refs/heads/dev-1.11.2")).toBe("1.11.2");
});
it("rejects the main branch", () => {
expect(() => parseDevBranch("main")).toThrow(/dev branch/);
});
it("rejects a dev branch without a semver version", () => {
expect(() => parseDevBranch("dev-foo")).toThrow(/valid semver/);
});
it("rejects a partial version", () => {
expect(() => parseDevBranch("dev-2.4")).toThrow(/valid semver/);
});
it("rejects an empty ref", () => {
expect(() => parseDevBranch("")).toThrow();
});
});
+105
View File
@@ -0,0 +1,105 @@
const fs = require("fs");
const path = require("path");
function extractYoutubeId(notes) {
const match = notes.match(
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
);
if (!match || !match[1].trim()) {
throw new Error(
"missing or empty <!-- YOUTUBE --> section in release notes",
);
}
return parseYoutubeId(match[1].trim());
}
function parseYoutubeId(raw) {
const value = raw.trim();
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
if (m) return m[1];
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
throw new Error(`could not parse a YouTube video id from "${value}"`);
}
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) {
throw new Error(
`token exchange failed (${res.status}): ${await res.text()}`,
);
}
const json = await res.json();
return json.access_token;
}
async function setVideoPublic(accessToken, videoId) {
const res = await fetch(
"https://www.googleapis.com/youtube/v3/videos?part=status",
{
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: videoId,
status: { privacyStatus: "public" },
}),
},
);
if (!res.ok) {
throw new Error(
`videos.update failed (${res.status}): ${await res.text()}`,
);
}
return res.json();
}
async function main() {
const clientId = process.env.YOUTUBE_CLIENT_ID;
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
if (!clientId || !clientSecret || !refreshToken) {
throw new Error(
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
);
}
const notesPath = path.resolve(
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
);
const notes = fs.readFileSync(notesPath, "utf8");
const videoId = extractYoutubeId(notes);
const accessToken = await getAccessToken({
clientId,
clientSecret,
refreshToken,
});
await setVideoPublic(accessToken, videoId);
console.log(`Set YouTube video ${videoId} to public.`);
}
module.exports = { extractYoutubeId, parseYoutubeId };
if (require.main === module) {
main().catch((err) => {
console.error(`publish-youtube: ${err.message}`);
process.exit(1);
});
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
describe("parseYoutubeId", () => {
it("parses a youtu.be short link", () => {
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("parses a watch?v= link", () => {
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("parses an embed link", () => {
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
"At8iDk6-Q_s",
);
});
it("accepts a bare id", () => {
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
});
it("throws on garbage", () => {
expect(() => parseYoutubeId("https://example.com/")).toThrow();
});
});
describe("extractYoutubeId", () => {
it("pulls the id from a release notes YOUTUBE section", () => {
const notes = [
"<!-- SUMMARY -->",
"stuff",
"<!-- /SUMMARY -->",
"<!-- YOUTUBE -->",
"https://youtu.be/At8iDk6-Q_s",
"<!-- /YOUTUBE -->",
].join("\n");
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
});
it("throws when the YOUTUBE section is missing", () => {
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
});
it("throws when the YOUTUBE section is empty", () => {
expect(() =>
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
).toThrow(/empty/);
});
});
+79
View File
@@ -0,0 +1,79 @@
const fs = require("fs");
const path = require("path");
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
function readJsonWithTrailingNewline(filePath) {
const raw = fs.readFileSync(filePath, "utf8");
return { data: JSON.parse(raw), hadTrailingNewline: raw.endsWith("\n") };
}
function writeJson(filePath, data, hadTrailingNewline) {
const serialized = JSON.stringify(data, null, 2);
fs.writeFileSync(
filePath,
hadTrailingNewline ? serialized + "\n" : serialized,
);
}
function syncVersion(version, options = {}) {
if (!SEMVER.test(version || "")) {
throw new Error(`invalid version "${version}" (expected X.Y.Z)`);
}
const root = options.root || path.join(__dirname, "..");
const pkgPath = path.join(root, "package.json");
const lockPath = path.join(root, "package-lock.json");
const changed = [];
const pkg = readJsonWithTrailingNewline(pkgPath);
if (pkg.data.version !== version) {
pkg.data.version = version;
writeJson(pkgPath, pkg.data, pkg.hadTrailingNewline);
changed.push("package.json");
}
if (fs.existsSync(lockPath)) {
const lock = readJsonWithTrailingNewline(lockPath);
let lockChanged = false;
if (lock.data.version !== version) {
lock.data.version = version;
lockChanged = true;
}
if (lock.data.packages && lock.data.packages[""]) {
if (lock.data.packages[""].version !== version) {
lock.data.packages[""].version = version;
lockChanged = true;
}
}
if (lockChanged) {
writeJson(lockPath, lock.data, lock.hadTrailingNewline);
changed.push("package-lock.json");
}
}
return changed;
}
module.exports = { syncVersion };
if (require.main === module) {
const args = process.argv.slice(2);
const idx = args.indexOf("--version");
const version = idx !== -1 ? args[idx + 1] : undefined;
try {
const changed = syncVersion(version);
if (changed.length === 0) {
console.log(`sync-version: already at ${version}, no change`);
} else {
console.log(`sync-version: set ${version} in ${changed.join(", ")}`);
}
} catch (err) {
console.error(`sync-version: ${err.message}`);
process.exit(1);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";
const require = createRequire(import.meta.url);
const { syncVersion } = require("./sync-version.cjs");
let root: string;
function pkg() {
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
}
function lock() {
return JSON.parse(
fs.readFileSync(path.join(root, "package-lock.json"), "utf8"),
);
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "sync-version-"));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "termix", version: "2.3.2" }, null, 2) + "\n",
);
fs.writeFileSync(
path.join(root, "package-lock.json"),
JSON.stringify(
{
name: "termix",
version: "2.3.2",
lockfileVersion: 3,
packages: { "": { name: "termix", version: "2.3.2" } },
},
null,
2,
) + "\n",
);
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe("syncVersion", () => {
it("updates package.json and both package-lock version fields", () => {
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual(["package.json", "package-lock.json"]);
expect(pkg().version).toBe("2.4.0");
expect(lock().version).toBe("2.4.0");
expect(lock().packages[""].version).toBe("2.4.0");
});
it("is idempotent when already at the target version", () => {
syncVersion("2.4.0", { root });
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual([]);
});
it("preserves the trailing newline", () => {
syncVersion("2.4.0", { root });
expect(fs.readFileSync(path.join(root, "package.json"), "utf8")).toMatch(
/\}\n$/,
);
});
it("rejects an invalid version", () => {
expect(() => syncVersion("2.4", { root })).toThrow(/invalid version/);
});
it("works when only the lock root version is stale", () => {
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "termix", version: "2.4.0" }, null, 2) + "\n",
);
const changed = syncVersion("2.4.0", { root });
expect(changed).toEqual(["package-lock.json"]);
});
});
+3 -166
View File
@@ -1,14 +1,9 @@
import express from "express";
import cookieParser from "cookie-parser";
import { createCorsMiddleware } from "./utils/cors-config.js";
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
import {
recentActivity,
hosts,
hostAccess,
dashboardPreferences,
} from "./database/db/schema.js";
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { getDb } from "./database/db/index.js";
import { recentActivity, hosts, hostAccess } from "./database/db/schema.js";
import { eq, and, desc, inArray } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
@@ -354,164 +349,6 @@ app.delete("/activity/reset", async (req, res) => {
}
});
/**
* @openapi
* /dashboard/preferences:
* get:
* summary: Get dashboard layout preferences
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
* tags:
* - Dashboard
* responses:
* 200:
* description: Dashboard preferences retrieved
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* 401:
* description: Session expired
* 500:
* description: Failed to get preferences
*/
app.get("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const preferences = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (preferences.length === 0) {
const defaultLayout = {
cards: [
{ id: "server_overview", enabled: true, order: 1 },
{ id: "recent_activity", enabled: true, order: 2 },
{ id: "network_graph", enabled: false, order: 3 },
{ id: "quick_actions", enabled: true, order: 4 },
{ id: "server_stats", enabled: true, order: 5 },
],
};
return res.json(defaultLayout);
}
const layout = JSON.parse(preferences[0].layout as string);
res.json(layout);
} catch (err) {
dashboardLogger.error("Failed to get dashboard preferences", err);
res.status(500).json({ error: "Failed to get dashboard preferences" });
}
});
/**
* @openapi
* /dashboard/preferences:
* post:
* summary: Save dashboard layout preferences
* description: Saves or updates the user's customized dashboard layout settings.
* tags:
* - Dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* cards:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* enabled:
* type: boolean
* order:
* type: integer
* responses:
* 200:
* description: Preferences saved successfully
* 400:
* description: Invalid request body
* 401:
* description: Session expired
* 500:
* description: Failed to save preferences
*/
app.post("/dashboard/preferences", async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const { cards, mainWidthPct } = req.body;
if (!cards || !Array.isArray(cards)) {
return res.status(400).json({
error: "Invalid request body. Expected { cards: Array }",
});
}
const layoutObj: Record<string, unknown> = { cards };
if (typeof mainWidthPct === "number") {
layoutObj.mainWidthPct = mainWidthPct;
}
const layout = JSON.stringify(layoutObj);
const existing = await getDb()
.select()
.from(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
if (existing.length > 0) {
await getDb()
.update(dashboardPreferences)
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(eq(dashboardPreferences.userId, userId));
} else {
await getDb().insert(dashboardPreferences).values({ userId, layout });
}
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
dashboardLogger.success("Dashboard preferences saved", {
operation: "save_dashboard_preferences",
userId,
});
res.json({ success: true, message: "Dashboard preferences saved" });
} catch (err) {
dashboardLogger.error("Failed to save dashboard preferences", err);
res.status(500).json({ error: "Failed to save dashboard preferences" });
}
});
const PORT = 30006;
app.listen(PORT, async () => {
try {
+8
View File
@@ -10,11 +10,15 @@ import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
import userPreferencesRoutes from "./routes/user-preferences.js";
import proxmoxRoutes from "./routes/proxmox.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
@@ -1777,11 +1781,15 @@ app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
app.use("/user-preferences", userPreferencesRoutes);
app.use("/proxmox", proxmoxRoutes);
registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
+217 -24
View File
@@ -488,6 +488,62 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_metrics_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host
ON host_metrics_preferences (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
checks TEXT NOT NULL,
interval_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_health_checks_user_host
ON host_health_checks (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
check_id TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ok INTEGER NOT NULL,
latency_ms INTEGER,
detail TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_host_health_history_lookup
ON host_health_history (user_id, host_id, check_id, ts);
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
try {
@@ -627,6 +683,17 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
addColumnIfNotExists("user_preferences", "language", "TEXT");
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
@@ -664,6 +731,16 @@ const migrateSchema = () => {
"enable_terminal",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_session_logging",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_command_history",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_tunnel",
@@ -714,6 +791,17 @@ const migrateSchema = () => {
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_proxmox",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "proxmox_config", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_tmux_monitor",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
addColumnIfNotExists("ssh_data", "domain", "TEXT");
@@ -999,30 +1087,6 @@ const migrateSchema = () => {
}
}
try {
sqlite
.prepare("SELECT id FROM dashboard_preferences LIMIT 1")
.get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS dashboard_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL UNIQUE,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create dashboard_preferences table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
} catch {
@@ -1183,6 +1247,25 @@ const migrateSchema = () => {
}
}
// Rename the legacy "stats" tab type to "host-metrics" so previously saved
// open tabs restore correctly after the Server Stats -> Host Metrics rename.
try {
sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
const result = sqlite
.prepare(
"UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
)
.run();
if (result.changes > 0) {
databaseLogger.info(
`Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
{ operation: "open_tabs_tab_type_migration" },
);
}
} catch {
// user_open_tabs table not present yet; nothing to migrate.
}
try {
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
} catch {
@@ -1382,6 +1465,79 @@ const migrateSchema = () => {
}
}
// --- tmux-monitor begin ---
try {
sqlite.prepare("SELECT id FROM tmux_session_tags LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS tmux_session_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create tmux_session_tags table", {
operation: "schema_migration",
error: createError,
});
}
}
// Rebuild pre-release tables created without the host FK so deleting a
// host cascades to its tags (SQLite cannot add a FK via ALTER TABLE).
try {
const tagFks = sqlite
.prepare("PRAGMA foreign_key_list(tmux_session_tags)")
.all() as Array<{ table: string; from: string }>;
const hasHostFk = tagFks.some(
(fk) => fk.from === "host_id" && fk.table === "ssh_data",
);
if (!hasHostFk) {
sqlite.exec(`
BEGIN;
CREATE TABLE tmux_session_tags_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
session_name TEXT NOT NULL,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
INSERT INTO tmux_session_tags_new (id, user_id, host_id, session_name, tag, created_at)
SELECT id, user_id, host_id, session_name, tag, created_at
FROM tmux_session_tags
WHERE user_id IN (SELECT id FROM users)
AND host_id IN (SELECT id FROM ssh_data);
DROP TABLE tmux_session_tags;
ALTER TABLE tmux_session_tags_new RENAME TO tmux_session_tags;
COMMIT;
`);
databaseLogger.info("Rebuilt tmux_session_tags with host FK", {
operation: "schema_migration",
});
}
} catch (rebuildError) {
try {
sqlite.exec("ROLLBACK;");
} catch {
// no transaction open
}
databaseLogger.warn("Failed to add host FK to tmux_session_tags", {
operation: "schema_migration",
error: rebuildError,
});
}
// --- tmux-monitor end ---
try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
@@ -1487,6 +1643,43 @@ const migrateSchema = () => {
});
}
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
// Migrate legacy single oidc_config settings blob into sso_providers table
try {
const migrationDone = sqlite
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
.get();
if (!migrationDone) {
const providerCount = (
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number }
).c;
if (providerCount === 0) {
const legacyRow = sqlite
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get() as { value: string } | undefined;
if (legacyRow) {
sqlite
.prepare(
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
)
.run("OIDC", legacyRow.value);
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
operation: "sso_migration_v1",
});
}
}
sqlite
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
.run();
}
} catch (e) {
databaseLogger.warn("Failed to run SSO migration v1", {
operation: "sso_migration_v1",
error: e,
});
}
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
+110 -15
View File
@@ -9,6 +9,7 @@ export const users = sqliteTable("users", {
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidcIdentifier: text("oidc_identifier"),
ssoProviderId: integer("sso_provider_id"),
clientId: text("client_id"),
clientSecret: text("client_secret"),
issuerUrl: text("issuer_url"),
@@ -30,6 +31,21 @@ export const settings = sqliteTable("settings", {
value: text("value").notNull(),
});
export const ssoProviders = sqliteTable("sso_providers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
displayOrder: integer("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
@@ -97,6 +113,12 @@ export const hosts = sqliteTable("ssh_data", {
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
.notNull()
.default(true),
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
.notNull()
.default(true),
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
.notNull()
.default(true),
@@ -108,6 +130,9 @@ export const hosts = sqliteTable("ssh_data", {
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
enableTmuxMonitor: integer("enable_tmux_monitor", { mode: "boolean" })
.notNull()
.default(false),
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
.notNull()
.default(true),
@@ -126,6 +151,10 @@ export const hosts = sqliteTable("ssh_data", {
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
dockerConfig: text("docker_config"),
enableProxmox: integer("enable_proxmox", { mode: "boolean" })
.notNull()
.default(false),
proxmoxConfig: text("proxmox_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
@@ -441,21 +470,6 @@ export const networkTopology = sqliteTable("network_topology", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
@@ -680,7 +694,88 @@ export const userPreferences = sqliteTable("user_preferences", {
fontSize: text("font_size"),
accentColor: text("accent_color"),
language: text("language"),
storageMode: text("storage_mode"),
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
showHostTags: integer("show_host_tags", { mode: "boolean" }),
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
pinAppRail: integer("pin_app_rail", { mode: "boolean" }),
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = sqliteTable("host_health_checks", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthHistory = sqliteTable("host_health_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
// --- tmux-monitor begin ---
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
sessionName: text("session_name").notNull(),
tag: text("tag").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- tmux-monitor end ---
@@ -0,0 +1,156 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { eq, and, gte, lte, sql } from "drizzle-orm";
import { db } from "../db/index.js";
import { auditLogs, users } from "../db/schema.js";
import { apiLogger } from "../../utils/logger.js";
export function registerAuditLogRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /audit-logs:
* get:
* summary: List audit logs
* description: Returns paginated, filterable audit log entries. Admin only.
* tags:
* - Audit
* parameters:
* - in: query
* name: page
* schema: { type: integer, default: 1 }
* - in: query
* name: limit
* schema: { type: integer, default: 50, maximum: 200 }
* - in: query
* name: userId
* schema: { type: string }
* - in: query
* name: action
* schema: { type: string }
* - in: query
* name: resourceType
* schema: { type: string }
* - in: query
* name: success
* schema: { type: string, enum: [true, false] }
* - in: query
* name: startDate
* schema: { type: string, format: date-time }
* - in: query
* name: endDate
* schema: { type: string, format: date-time }
* responses:
* 200:
* description: Paginated list of audit logs.
* 403:
* description: Not authorized.
* 500:
* description: Failed to fetch audit logs.
*/
router.get("/audit-logs", authenticateJWT, async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const adminUser = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, authReq.userId))
.limit(1);
if (!adminUser[0]?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const page = Math.max(1, parseInt(String(req.query.page || "1"), 10));
const limit = Math.min(
200,
Math.max(1, parseInt(String(req.query.limit || "50"), 10)),
);
const offset = (page - 1) * limit;
const { userId, action, resourceType, success, startDate, endDate } =
req.query as Record<string, string | undefined>;
const conditions = [];
if (userId) conditions.push(eq(auditLogs.userId, userId));
if (action) conditions.push(eq(auditLogs.action, action));
if (resourceType)
conditions.push(eq(auditLogs.resourceType, resourceType));
if (success !== undefined && success !== "") {
conditions.push(eq(auditLogs.success, success === "true"));
}
if (startDate) conditions.push(gte(auditLogs.timestamp, startDate));
if (endDate) conditions.push(lte(auditLogs.timestamp, endDate));
const whereClause =
conditions.length > 0 ? and(...conditions) : undefined;
const [logs, totalResult] = await Promise.all([
db
.select()
.from(auditLogs)
.where(whereClause)
.orderBy(sql`${auditLogs.timestamp} DESC`)
.limit(limit)
.offset(offset),
db
.select({ count: sql<number>`COUNT(*)` })
.from(auditLogs)
.where(whereClause),
]);
const total = totalResult[0]?.count ?? 0;
const totalPages = Math.ceil(total / limit);
return res.json({ logs, total, page, totalPages });
} catch (err) {
apiLogger.error("Failed to fetch audit logs", err);
return res.status(500).json({ error: "Failed to fetch audit logs" });
}
});
/**
* @openapi
* /audit-logs/actions:
* get:
* summary: List distinct audit log action types
* description: Returns all distinct action values in the audit log for filter dropdowns. Admin only.
* tags:
* - Audit
* responses:
* 200:
* description: List of distinct action strings.
* 403:
* description: Not authorized.
* 500:
* description: Failed to fetch audit log actions.
*/
router.get("/audit-logs/actions", authenticateJWT, async (req, res) => {
try {
const authReq = req as AuthenticatedRequest;
const adminUser = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, authReq.userId))
.limit(1);
if (!adminUser[0]?.isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const rows = db.$client
.prepare("SELECT DISTINCT action FROM audit_logs ORDER BY action ASC")
.all() as { action: string }[];
return res.json({ actions: rows.map((r) => r.action) });
} catch (err) {
apiLogger.error("Failed to fetch audit log actions", err);
return res
.status(500)
.json({ error: "Failed to fetch audit log actions" });
}
});
}
@@ -15,6 +15,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const router = express.Router();
@@ -186,6 +187,25 @@ router.post(
userId,
)) as typeof credentialData & { id: number };
const { ipAddress: ccIp, userAgent: ccUa } = getRequestMeta(req);
const { users: usersTableCc } = await import("../db/schema.js");
const ccActor = await db
.select({ username: usersTableCc.username })
.from(usersTableCc)
.where(eq(usersTableCc.id, userId))
.limit(1);
await logAudit({
userId,
username: ccActor[0]?.username ?? userId,
action: "create_credential",
resourceType: "credential",
resourceId: String(created.id),
resourceName: name,
ipAddress: ccIp,
userAgent: ccUa,
success: true,
});
authLogger.success(
`SSH credential created: ${name} (${authType}) by user ${userId}`,
{
@@ -567,6 +587,25 @@ router.put(
credentialId: parseInt(id),
});
const { ipAddress: cuIp, userAgent: cuUa } = getRequestMeta(req);
const { users: usersTableCu } = await import("../db/schema.js");
const cuActor = await db
.select({ username: usersTableCu.username })
.from(usersTableCu)
.where(eq(usersTableCu.id, userId))
.limit(1);
await logAudit({
userId,
username: cuActor[0]?.username ?? userId,
action: "update_credential",
resourceType: "credential",
resourceId: id,
resourceName: existing[0].name,
ipAddress: cuIp,
userAgent: cuUa,
success: true,
});
res.json(formatCredentialOutput(updated[0]));
} catch (err) {
authLogger.error("Failed to update credential", err);
@@ -697,6 +736,25 @@ router.delete(
credentialId: parseInt(id),
});
const { ipAddress: cdIp, userAgent: cdUa } = getRequestMeta(req);
const { users: usersTableCd } = await import("../db/schema.js");
const cdActor = await db
.select({ username: usersTableCd.username })
.from(usersTableCd)
.where(eq(usersTableCd.id, userId))
.limit(1);
await logAudit({
userId,
username: cdActor[0]?.username ?? userId,
action: "delete_credential",
resourceType: "credential",
resourceId: id,
resourceName: credentialToDelete[0].name,
ipAddress: cdIp,
userAgent: cdUa,
success: true,
});
res.json({ message: "Credential deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete credential", err);
@@ -4,7 +4,6 @@ import { db } from "../db/index.js";
import {
auditLogs,
commandHistory,
dashboardPreferences,
dismissedAlerts,
fileManagerPinned,
fileManagerRecent,
@@ -77,9 +76,6 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
await db
.delete(dashboardPreferences)
.where(eq(dashboardPreferences.userId, userId));
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
@@ -100,7 +100,12 @@ export function registerHostBulkRoutes(
try {
const ownedHosts = await db
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
.select({
id: hosts.id,
statsConfig: hosts.statsConfig,
credentialId: hosts.credentialId,
proxmoxConfig: hosts.proxmoxConfig,
})
.from(hosts)
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
@@ -132,6 +137,12 @@ export function registerHostBulkRoutes(
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableDocker === "boolean")
simpleUpdates.enableDocker = updates.enableDocker;
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
// Disabling Proxmox is a plain flag flip; enabling is handled per-host
// below so each host can default to its own stored credential.
if (updates.enableProxmox === false)
simpleUpdates.enableProxmox = false;
if (Object.keys(simpleUpdates).length > 0) {
await db
@@ -157,6 +168,37 @@ export function registerHostBulkRoutes(
}
}
// Enabling Proxmox needs per-host handling: each host defaults its
// Proxmox credential to the credential already stored on that host, so
// discovery works right away without picking one by hand. Existing
// proxmoxConfig values are preserved.
if (updates.enableProxmox === true) {
for (const host of ownedHosts) {
try {
const existing = host.proxmoxConfig
? JSON.parse(host.proxmoxConfig as string)
: {};
const merged = {
defaultCredentialId:
existing.defaultCredentialId ?? host.credentialId ?? null,
windowsPatterns: existing.windowsPatterns ?? "win, windows",
dockerPatterns: existing.dockerPatterns ?? "docker",
preferredPrefixes:
existing.preferredPrefixes ?? "10., 192.168.",
};
await db
.update(hosts)
.set({
enableProxmox: true,
proxmoxConfig: JSON.stringify(merged),
})
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch {
errors.push(`Failed to enable Proxmox for host ${host.id}`);
}
}
}
DatabaseSaveTrigger.triggerSave("bulk_update");
return res.json({
@@ -244,13 +286,18 @@ export function registerHostBulkRoutes(
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
!["password", "key", "credential", "none", "opkssh"].includes(
hostData.authType,
)
![
"password",
"key",
"credential",
"none",
"opkssh",
"tailscale",
].includes(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', or 'opkssh'`,
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', or 'tailscale'`,
);
continue;
}
@@ -340,6 +387,8 @@ export function registerHostBulkRoutes(
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
enableProxmox: hostData.enableProxmox || false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
@@ -362,6 +411,9 @@ export function registerHostBulkRoutes(
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
proxmoxConfig: hostData.proxmoxConfig
? JSON.stringify(hostData.proxmoxConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
@@ -1,9 +1,9 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { and, eq, inArray, or } from "drizzle-orm";
import { and, eq, inArray, like, or, sql } from "drizzle-orm";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import {
commandHistory,
fileManagerPinned,
@@ -75,27 +75,32 @@ export function registerHostFolderRoutes(
}
try {
const updatedHosts = await SimpleDBOps.update(
hosts,
"ssh_data",
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
},
userId,
);
const now = new Date().toISOString();
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
const childLike = `${oldPrefix}%`;
// folder is a plaintext column, so a SQL expression renames the exact
// folder and re-paths every nested child in one statement.
const renameExpr = (col: SQLiteColumn) =>
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, oldName), like(col, childLike));
const updatedHosts = await db
.update(hosts)
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
.returning();
const updatedCredentials = await db
.update(sshCredentials)
.set({
folder: newName,
updatedAt: new Date().toISOString(),
})
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
folderMatch(sshCredentials.folder),
),
)
.returning();
@@ -104,12 +109,9 @@ export function registerHostFolderRoutes(
await db
.update(sshFolders)
.set({
name: newName,
updatedAt: new Date().toISOString(),
})
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
);
res.json({
@@ -306,17 +308,15 @@ export function registerHostFolderRoutes(
});
try {
// Match the folder itself and any nested children (e.g. "fgh / sub").
const childLike = `${folderName} / %`;
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, childLike));
const hostsToDelete = await db
.select()
.from(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
message: "No hosts found in folder",
deletedCount: 0,
});
}
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
const hostIds = hostsToDelete.map((host) => host.id);
@@ -363,14 +363,18 @@ export function registerHostFolderRoutes(
.where(inArray(sessionRecordings.hostId, hostIds));
}
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostIds.length > 0) {
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
// Always remove the folder records (and nested children), even when the
// folder held no hosts, so empty folders don't reappear on reload.
await db
.delete(sshFolders)
.where(
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
);
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
@@ -3,6 +3,7 @@ import {
isNonEmptyString,
isValidPort,
normalizeImportedHost,
renameFolderPath,
stripSensitiveFields,
transformHostResponse,
} from "./host-normalizers.js";
@@ -22,6 +23,42 @@ describe("isNonEmptyString", () => {
});
});
describe("renameFolderPath", () => {
it("renames an exact folder match", () => {
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
});
it("re-paths nested children under the renamed ancestor", () => {
expect(renameFolderPath("Production / Web", "Production", "Prod")).toBe(
"Prod / Web",
);
expect(
renameFolderPath("Production / Web / app01", "Production", "Prod"),
).toBe("Prod / Web / app01");
});
it("renames a nested folder itself and keeps its parent", () => {
expect(
renameFolderPath("Production / Web", "Production / Web", "Frontend"),
).toBe("Frontend");
expect(
renameFolderPath(
"Production / Web / app01",
"Production / Web",
"Production / Frontend",
),
).toBe("Production / Frontend / app01");
});
it("returns null for unrelated folders", () => {
expect(renameFolderPath("Staging", "Production", "Prod")).toBeNull();
expect(renameFolderPath("Production2", "Production", "Prod")).toBeNull();
expect(
renameFolderPath("ProductionExtra / Web", "Production", "Prod"),
).toBeNull();
});
});
describe("isValidPort", () => {
it("accepts ports in range", () => {
expect(isValidPort(1)).toBe(true);
@@ -153,4 +190,22 @@ describe("transformHostResponse", () => {
expect(result.vncPort).toBe(5900);
expect(result.telnetPort).toBe(23);
});
it("coerces enableProxmox and parses proxmoxConfig", () => {
const result = transformHostResponse({
enableProxmox: 1,
proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}',
});
expect(result.enableProxmox).toBe(true);
expect(result.proxmoxConfig).toEqual({
defaultCredentialId: 3,
windowsPatterns: "win",
});
});
it("defaults enableProxmox to false when absent", () => {
const result = transformHostResponse({ port: 22 });
expect(result.enableProxmox).toBe(false);
expect(result.proxmoxConfig).toBeUndefined();
});
});
@@ -6,6 +6,26 @@ export function isValidPort(port: unknown): port is number {
return typeof port === "number" && port > 0 && port <= 65535;
}
export const FOLDER_PATH_SEPARATOR = " / ";
/**
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
* path for an exact match or any nested child, or null when the path is unrelated.
* Mirrors the SQL CASE expression used in the folder rename route.
*/
export function renameFolderPath(
folderPath: string,
oldName: string,
newName: string,
): string | null {
if (folderPath === oldName) return newName;
const prefix = `${oldName}${FOLDER_PATH_SEPARATOR}`;
if (folderPath.startsWith(prefix)) {
return `${newName}${FOLDER_PATH_SEPARATOR}${folderPath.slice(prefix.length)}`;
}
return null;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
@@ -79,6 +99,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
enableTunnel?: unknown;
enableFileManager?: unknown;
enableDocker?: unknown;
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
@@ -91,6 +113,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
quickActions?: unknown;
statsConfig?: unknown;
dockerConfig?: unknown;
proxmoxConfig?: unknown;
terminalConfig?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
@@ -216,6 +239,8 @@ export function transformHostResponse(
enableTunnel: !!host.enableTunnel,
enableFileManager: !!host.enableFileManager,
enableDocker: !!host.enableDocker,
enableProxmox: !!host.enableProxmox,
enableTmuxMonitor: !!host.enableTmuxMonitor,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
@@ -264,6 +289,9 @@ export function transformHostResponse(
dockerConfig: host.dockerConfig
? JSON.parse(host.dockerConfig as string)
: undefined,
proxmoxConfig: host.proxmoxConfig
? JSON.parse(host.proxmoxConfig as string)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
+110 -118
View File
@@ -25,6 +25,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { pickResolvedUsername } from "../../ssh/credential-username.js";
import {
isNonEmptyString,
isValidPort,
@@ -39,6 +40,7 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const router = express.Router();
@@ -152,6 +154,8 @@ router.post(
enableTunnel,
enableFileManager,
enableDocker,
enableProxmox,
enableTmuxMonitor,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -163,6 +167,7 @@ router.post(
quickActions,
statsConfig,
dockerConfig,
proxmoxConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -252,6 +257,8 @@ router.post(
: null,
enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -268,6 +275,11 @@ router.post(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -399,6 +411,25 @@ router.post(
name,
});
const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req);
const { users: usersTable } = await import("../db/schema.js");
const chActor = await db
.select({ username: usersTable.username })
.from(usersTable)
.where(eq(usersTable.id, userId))
.limit(1);
await logAudit({
userId,
username: chActor[0]?.username ?? userId,
action: "create_host",
resourceType: "host",
resourceId: String(createdHost.id),
resourceName: String(name ?? ip),
ipAddress: chIp,
userAgent: chUa,
success: true,
});
res.json(resolvedHost);
notifyStatsHostUpdated(
createdHost.id as number,
@@ -571,6 +602,8 @@ router.post(
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
enableProxmox: false,
enableTmuxMonitor: false,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
@@ -690,6 +723,8 @@ router.put(
enableTunnel,
enableFileManager,
enableDocker,
enableProxmox,
enableTmuxMonitor,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -701,6 +736,7 @@ router.put(
quickActions,
statsConfig,
dockerConfig,
proxmoxConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -787,6 +823,8 @@ router.put(
: null,
enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -803,6 +841,11 @@ router.put(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? proxmoxConfig
: JSON.stringify(proxmoxConfig)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -1030,6 +1073,25 @@ router.put(
hostId: parseInt(hostId),
});
const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req);
const { users: usersTableUpd } = await import("../db/schema.js");
const uhActor = await db
.select({ username: usersTableUpd.username })
.from(usersTableUpd)
.where(eq(usersTableUpd.id, userId))
.limit(1);
await logAudit({
userId,
username: uhActor[0]?.username ?? userId,
action: "update_host",
resourceType: "host",
resourceId: hostId,
resourceName: String(name ?? ip),
ipAddress: uhIp,
userAgent: uhUa,
success: true,
});
res.json(resolvedHost);
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
} catch (err) {
@@ -1122,6 +1184,8 @@ router.get(
quickActions: hosts.quickActions,
notes: hosts.notes,
enableDocker: hosts.enableDocker,
enableProxmox: hosts.enableProxmox,
enableTmuxMonitor: hosts.enableTmuxMonitor,
showTerminalInSidebar: hosts.showTerminalInSidebar,
showFileManagerInSidebar: hosts.showFileManagerInSidebar,
showTunnelInSidebar: hosts.showTunnelInSidebar,
@@ -1140,6 +1204,7 @@ router.get(
guacamoleConfig: hosts.guacamoleConfig,
macAddress: hosts.macAddress,
dockerConfig: hosts.dockerConfig,
proxmoxConfig: hosts.proxmoxConfig,
enableSsh: hosts.enableSsh,
enableRdp: hosts.enableRdp,
enableVnc: hosts.enableVnc,
@@ -1511,6 +1576,8 @@ router.get(
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -1533,6 +1600,9 @@ router.get(
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
proxmoxConfig: resolvedHost.proxmoxConfig
? JSON.parse(resolvedHost.proxmoxConfig as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -1571,7 +1641,7 @@ router.get(
/**
* @openapi
* /ssh/db/hosts/export:
* /host/db/hosts/export:
* get:
* summary: Export all SSH hosts
* description: Exports all SSH hosts for the current user with decrypted credentials.
@@ -1654,6 +1724,8 @@ router.get(
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
enableDocker: !!resolvedHost.enableDocker,
enableProxmox: !!resolvedHost.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -1676,6 +1748,9 @@ router.get(
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
proxmoxConfig: resolvedHost.proxmoxConfig
? JSON.parse(resolvedHost.proxmoxConfig as string)
: null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -1713,7 +1788,7 @@ router.get(
/**
* @openapi
* /ssh/db/host/{id}:
* /host/db/host/{id}:
* delete:
* summary: Delete SSH host
* description: Deletes an SSH host by its ID.
@@ -1824,6 +1899,25 @@ router.delete(
hostId: parseInt(hostId),
});
const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req);
const { users: usersTableDel } = await import("../db/schema.js");
const dhActor = await db
.select({ username: usersTableDel.username })
.from(usersTableDel)
.where(eq(usersTableDel.id, userId))
.limit(1);
await logAudit({
userId,
username: dhActor[0]?.username ?? userId,
action: "delete_host",
resourceType: "host",
resourceId: hostId,
resourceName: hostToDelete[0].name ?? hostToDelete[0].ip,
ipAddress: dhIp,
userAgent: dhUa,
success: true,
});
try {
const axios = (await import("axios")).default;
await axios.post(
@@ -2002,11 +2096,13 @@ async function resolveHostCredentials(
keyType: sharedCred.keyType,
};
if (
!host.overrideCredentialUsername &&
isNonEmptyString(sharedCred.username)
) {
resolvedHost.username = sharedCred.username;
const resolvedUsername = pickResolvedUsername(
host.username,
sharedCred.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
@@ -2051,11 +2147,13 @@ async function resolveHostCredentials(
keyType: credential.keyType,
};
if (
!host.overrideCredentialUsername &&
isNonEmptyString(credential.username)
) {
resolvedHost.username = credential.username;
const resolvedUsername = pickResolvedUsername(
host.username,
credential.username,
host.overrideCredentialUsername,
);
if (resolvedUsername !== undefined) {
resolvedHost.username = resolvedUsername;
}
return resolvedHost;
@@ -2078,112 +2176,6 @@ registerHostFolderRoutes(router, {
registerHostBulkRoutes(router, authenticateJWT);
/**
* @openapi
* /host/folders/{folderName}/hosts:
* delete:
* summary: Delete all hosts in a folder
* description: Deletes all hosts within a specific folder.
* tags:
* - SSH
* parameters:
* - in: path
* name: folderName
* required: true
* schema:
* type: string
* responses:
* 200:
* description: All hosts deleted successfully.
* 400:
* description: Invalid folder name.
* 500:
* description: Failed to delete hosts.
*/
router.delete(
"/folders/:folderName/hosts",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const folderName = decodeURIComponent(
Array.isArray(req.params.folderName)
? req.params.folderName[0]
: req.params.folderName,
);
if (!folderName) {
return res.status(400).json({ error: "Folder name is required" });
}
try {
const hostsToDelete = await db
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({ deletedCount: 0 });
}
const hostIds = hostsToDelete.map((h) => h.id);
for (const hostId of hostIds) {
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId));
await db
.delete(transferRecent)
.where(
or(
eq(transferRecent.sourceHostId, hostId),
eq(transferRecent.destHostId, hostId),
),
);
await db
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId));
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.hostId, hostId));
await db
.delete(recentActivity)
.where(eq(recentActivity.hostId, hostId));
await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId));
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.hostId, hostId));
}
await db
.delete(hosts)
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
databaseLogger.success("All hosts in folder deleted", {
operation: "delete_folder_hosts",
userId,
folderName,
deletedCount: hostsToDelete.length,
});
res.json({ deletedCount: hostsToDelete.length });
} catch (error) {
sshLogger.error("Failed to delete hosts in folder", error, {
operation: "delete_folder_hosts",
userId,
folderName,
});
res.status(500).json({ error: "Failed to delete hosts in folder" });
}
},
);
registerHostAutostartRoutes(router, {
authenticateJWT,
requireDataAccess,
@@ -0,0 +1,428 @@
import type { Router } from "express";
import type { LDAPProviderConfig } from "../../../types/index.js";
import { db } from "../db/index.js";
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
import ldap from "ldapjs";
const authManager = AuthManager.getInstance();
function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
function createLDAPClient(
host: string,
port: number,
useTLS: boolean,
): ldap.Client {
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function ldapBind(
client: ldap.Client,
dn: string,
password: string,
): Promise<void> {
return new Promise((resolve, reject) => {
client.bind(dn, password, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function ldapSearch(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function ldapUnbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
/* best effort */
}
}
export function registerLDAPAuthRoutes(router: Router): void {
/**
* @openapi
* /users/ldap/login:
* post:
* summary: LDAP login
* description: Authenticates a user against an LDAP server.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* providerId:
* type: integer
* username:
* type: string
* password:
* type: string
* rememberMe:
* type: boolean
* responses:
* 200:
* description: Login successful.
* 400:
* description: Missing fields.
* 401:
* description: Invalid credentials.
* 403:
* description: User not allowed.
* 404:
* description: Provider not found.
*/
router.post("/ldap/login", async (req, res) => {
const { providerId, username, password, rememberMe } = req.body as {
providerId: number;
username: string;
password: string;
rememberMe?: boolean;
};
if (!providerId || !username || !password) {
return res
.status(400)
.json({ error: "providerId, username, and password are required" });
}
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (rows.length === 0 || rows[0].type !== "ldap" || !rows[0].enabled) {
return res.status(404).json({ error: "LDAP provider not found" });
}
} catch (err) {
authLogger.error("Failed to load LDAP provider", err);
return res.status(500).json({ error: "Failed to load LDAP provider" });
}
const providerResult = await loadProviderConfig(providerId);
if (!providerResult) {
return res.status(404).json({ error: "LDAP provider not found" });
}
const config = providerResult.config as unknown as LDAPProviderConfig;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
return res.status(500).json({ error: "LDAP provider is misconfigured" });
}
const serviceClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const attrList = [
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
];
const entries = await ldapSearch(
serviceClient,
config.userSearchBase,
filter,
attrList,
);
if (entries.length === 0) {
authLogger.warn("LDAP user not found", {
operation: "ldap_login",
username,
});
return res.status(401).json({ error: "Invalid username or password" });
}
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const getAttr = (key: string): string => {
const attr = userEntry.attributes.find((a) => a.type === key);
return attr
? Array.isArray(attr.values)
? attr.values[0]
: String(attr.values)
: "";
};
const ldapIdentifier =
getAttr(config.usernameAttribute || "uid") || username;
const displayName =
getAttr(config.displayNameAttribute || "cn") || username;
const email = getAttr("mail") || getAttr("email") || "";
if (config.allowedUsers) {
if (
!isOIDCUserAllowed(
config.allowedUsers,
ldapIdentifier,
email || undefined,
)
) {
authLogger.warn("LDAP user not in allowed list", {
operation: "ldap_user_not_allowed",
ldapIdentifier,
});
return res.status(403).json({ error: "User not allowed" });
}
}
// Re-bind with user's own credentials to verify password
const userClient = createLDAPClient(
config.host,
config.port || 389,
config.useTLS || false,
);
try {
await ldapBind(userClient, userDN, password);
} catch {
authLogger.warn("LDAP bind failed - wrong password", {
operation: "ldap_login",
ldapIdentifier,
});
return res.status(401).json({ error: "Invalid username or password" });
} finally {
ldapUnbind(userClient);
}
// Admin group check
let isAdmin = false;
if (config.adminGroup && config.groupSearchBase) {
try {
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
const groupEntries = await ldapSearch(
serviceClient,
config.groupSearchBase,
groupFilter,
["cn", "dn"],
);
isAdmin = groupEntries.some((g) => {
const cn = g.attributes.find((a) => a.type === "cn");
const cnVal = cn
? Array.isArray(cn.values)
? cn.values[0]
: String(cn.values)
: "";
return (
cnVal === config.adminGroup ||
g.dn.toString() === config.adminGroup
);
});
} catch (groupErr) {
authLogger.warn("LDAP group check failed", {
operation: "ldap_group_check",
error: groupErr,
});
}
}
ldapUnbind(serviceClient);
const deviceInfo = parseUserAgent(req);
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
let existingUsers = await db
.select()
.from(users)
.where(eq(users.oidcIdentifier, oidcIdentifier));
let userId: string;
if (existingUsers.length === 0) {
let autoProvision = false;
try {
const r = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
)
.get() as { value: string } | undefined;
if (r) autoProvision = r.value === "true";
} catch {
/* */
}
if (!autoProvision)
autoProvision =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
const countRow = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number };
const isFirst = (countRow?.count || 0) === 0;
if (!isFirst && !autoProvision) {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get() as { value: string } | undefined;
if (regRow && regRow.value !== "true") {
return res.status(403).json({ error: "Registration is disabled" });
}
}
userId = nanoid();
const isFirstFinal = db.$client.transaction(() => {
const c =
(
db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number }
)?.count || 0;
const first = c === 0;
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.run(
userId,
displayName,
"",
first || isAdmin ? 1 : 0,
oidcIdentifier,
providerId,
);
return first;
})();
try {
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
const defaultRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, defaultRoleName))
.limit(1);
if (defaultRole.length > 0)
await db
.insert(userRoles)
.values({ userId, roleId: defaultRole[0].id, grantedBy: userId });
} catch {
/* */
}
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(userId, sessionDurationMs);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, userId));
authLogger.error(
"Failed to setup LDAP user encryption",
encryptionError,
);
return res
.status(500)
.json({ error: "Failed to setup user security" });
}
existingUsers = await db
.select()
.from(users)
.where(eq(users.id, userId));
} else {
userId = existingUsers[0].id;
// Sync admin status from group membership
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
existingUsers[0].isAdmin = isAdmin;
}
// Update display name if not dual-auth
const isDualAuth =
existingUsers[0].passwordHash &&
existingUsers[0].passwordHash.trim() !== "";
if (!isDualAuth && existingUsers[0].username !== displayName) {
await db
.update(users)
.set({ username: displayName })
.where(eq(users.id, userId));
}
}
const userRecord = existingUsers[0];
try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch {
/* */
}
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: rememberMe || false,
});
authLogger.success("LDAP login successful", {
operation: "ldap_login_complete",
userId: userRecord.id,
username: userRecord.username,
});
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: rememberMe
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.json({ success: true, message: "Login successful" });
} catch (err) {
ldapUnbind(serviceClient);
authLogger.error("LDAP login failed", err);
return res.status(500).json({ error: "LDAP authentication failed" });
}
});
}
+13 -1
View File
@@ -25,6 +25,16 @@ const authenticateJWT = authManager.createAuthMiddleware();
*/
const TAB_TTL_MS = 30 * 60 * 1000;
// Legacy tab types that were renamed. Normalize on read so previously saved
// tabs still restore to the correct (renamed) tab type.
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
stats: "host-metrics",
};
function normalizeTabType(tabType: string): string {
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
@@ -40,7 +50,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
)
.orderBy(userOpenTabs.tabOrder)
.all();
return res.json(tabs);
return res.json(
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
);
} catch (e) {
databaseLogger.error("Failed to get open tabs", e, {
operation: "get_open_tabs",
+565
View File
@@ -0,0 +1,565 @@
import express from "express";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../db/index.js";
import { hosts, sshCredentials } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { SSHHost } from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../../ssh/host-key-verifier.js";
const router = express.Router();
const proxmoxLogger = logger;
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
// but we validate defensively before using in a shell command.
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
function isSafeNodeName(name: string): boolean {
return SAFE_NODE_RE.test(name);
}
function execCommand(
client: SSHClient,
command: string,
timeoutMs = 8000,
): Promise<string> {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error(`Command timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
client.exec(command, (err, stream) => {
if (err) {
clearTimeout(timer);
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0)
reject(new Error(stderr || `Command exited with code ${code}`));
else resolve(stdout);
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
});
});
}
// Parse all IPs from LXC net config, then return the one matching the preferred prefix.
function parseLxcIp(
config: Record<string, unknown>,
preferredPrefixes: string[] = [],
): string | null {
const ips: string[] = [];
for (const [key, value] of Object.entries(config)) {
if (/^net\d+$/.test(key) && typeof value === "string") {
const m = value.match(/ip=(\d{1,3}(?:\.\d{1,3}){3})/);
if (m) ips.push(m[1]);
}
}
if (!ips.length) return null;
for (const prefix of preferredPrefixes) {
const match = ips.find((ip) => ip.startsWith(prefix));
if (match) return match;
}
return ips[0];
}
function matchesAny(name: string, patterns: string[]): boolean {
const lower = name.toLowerCase();
return patterns.some((p) => lower.includes(p.toLowerCase()));
}
function parseProxmoxConfig(raw: unknown): {
windowsPatterns: string[];
dockerPatterns: string[];
preferredPrefixes: string[];
} {
const split = (s: string) =>
s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (!raw || typeof raw !== "object") {
return {
windowsPatterns: ["win", "windows"],
dockerPatterns: ["docker"],
preferredPrefixes: [],
};
}
const cfg = raw as Record<string, unknown>;
return {
windowsPatterns: split(
typeof cfg.windowsPatterns === "string"
? cfg.windowsPatterns
: "win,windows",
),
dockerPatterns: split(
typeof cfg.dockerPatterns === "string" ? cfg.dockerPatterns : "docker",
),
preferredPrefixes: split(
typeof cfg.preferredPrefixes === "string" ? cfg.preferredPrefixes : "",
),
};
}
/**
* @openapi
* /proxmox/discover:
* post:
* summary: Discover Proxmox guests on a node
* description: >
* Connects to an existing SSH host (a Proxmox node) using its stored
* credentials, runs pvesh to enumerate all guests (VMs and LXC
* containers) in the cluster, and returns them ready to be imported as
* Termix hosts. No separate Proxmox API token is required.
* tags: [Proxmox]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [hostId]
* properties:
* hostId:
* type: number
* description: ID of the SSH host that is a Proxmox node.
* responses:
* 200:
* description: Discovered guests.
* content:
* application/json:
* schema:
* type: object
* properties:
* guests:
* type: array
* items:
* type: object
* properties:
* name:
* type: string
* vmid:
* type: number
* type:
* type: string
* enum: [qemu, lxc]
* node:
* type: string
* status:
* type: string
* ip:
* type: string
* nullable: true
* connectionType:
* type: string
* enum: [ssh, rdp]
* enableDocker:
* type: boolean
* credentialId:
* type: number
* nullable: true
* defaultCredentialId:
* type: number
* nullable: true
* 400:
* description: Missing or invalid hostId.
* 401:
* description: Authentication required or session expired.
* 403:
* description: Access denied to the host.
* 404:
* description: Host not found.
* 422:
* description: Host is not a Proxmox node or is unreachable.
* 500:
* description: Discovery failed.
*/
router.post(
"/discover",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
try {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
return res.status(401).json({
error: "Session expired — please log in again",
code: "SESSION_EXPIRED",
});
}
// -----------------------------------------------------------------------
// Load host from DB
// -----------------------------------------------------------------------
const hostResults = await SimpleDBOps.select(
getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)),
"ssh_data",
userId,
);
if (!hostResults.length) {
return res.status(404).json({ error: "Host not found" });
}
const host = hostResults[0] as unknown as SSHHost;
// Read discovery settings from the host's proxmoxConfig
const proxmoxCfgRaw = host.proxmoxConfig
? typeof host.proxmoxConfig === "string"
? JSON.parse(host.proxmoxConfig)
: host.proxmoxConfig
: null;
const { windowsPatterns, dockerPatterns, preferredPrefixes } =
parseProxmoxConfig(proxmoxCfgRaw);
const proxmoxDefaultCredentialId =
proxmoxCfgRaw && typeof proxmoxCfgRaw === "object"
? (((proxmoxCfgRaw as Record<string, unknown>).defaultCredentialId as
| number
| null) ?? null)
: null;
// -----------------------------------------------------------------------
// Permission check
// -----------------------------------------------------------------------
if (host.userId !== userId) {
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const pm = PermissionManager.getInstance();
const access = await pm.canAccessHost(userId, parsedHostId, "execute");
if (!access.hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
}
// -----------------------------------------------------------------------
// Credential resolution (mirrors docker.ts pattern)
// -----------------------------------------------------------------------
let resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
const hostCredentialId = host.credentialId ?? null;
if (host.credentialId) {
if (userId !== host.userId) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const sharedCred =
await SharedCredentialManager.getInstance().getSharedCredentialForUser(
host.id,
userId,
);
if (sharedCred) {
resolvedCredentials = {
password: sharedCred.password,
sshKey: sharedCred.key,
keyPassword: sharedCred.keyPassword,
authType: sharedCred.authType,
};
}
} catch (err) {
proxmoxLogger.error("Failed to resolve shared credential", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
}
} else {
const creds = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, host.credentialId as number),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (creds.length > 0) {
const c = creds[0];
resolvedCredentials = {
password: c.password as string | undefined,
sshKey: (c.key || c.privateKey) as string | undefined,
keyPassword: c.keyPassword as string | undefined,
authType: c.authType as string | undefined,
};
}
}
}
// -----------------------------------------------------------------------
// Build SSH config
// -----------------------------------------------------------------------
const sshConfig: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: false,
readyTimeout: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
parsedHostId,
host.ip,
host.port || 22,
null,
userId,
false,
),
};
const authType = resolvedCredentials.authType;
if (authType === "key" && resolvedCredentials.sshKey) {
sshConfig.privateKey = resolvedCredentials.sshKey;
if (resolvedCredentials.keyPassword)
sshConfig.passphrase = resolvedCredentials.keyPassword;
} else if (resolvedCredentials.password) {
sshConfig.password = resolvedCredentials.password;
}
// -----------------------------------------------------------------------
// Connect → discover → disconnect
// -----------------------------------------------------------------------
const client = new SSHClient();
try {
await new Promise<void>((resolve, reject) => {
client.on("ready", resolve);
client.on("error", reject);
client.connect(sshConfig as import("ssh2").ConnectConfig);
});
proxmoxLogger.info("Proxmox discovery SSH connection established", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
// Verify pvesh is present — fail fast with a clear error
const pveshCheck = await execCommand(
client,
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
);
if (pveshCheck.trim() !== "ok") {
return res
.status(422)
.json({ error: "pvesh not found — is this a Proxmox node?" });
}
// Fetch all cluster resources in one call
const resourcesJson = await execCommand(
client,
"pvesh get /cluster/resources --output-format json 2>/dev/null",
);
let resources: Array<Record<string, unknown>>;
try {
resources = JSON.parse(resourcesJson);
} catch {
return res.status(502).json({
error: "Failed to parse pvesh output — unexpected response",
});
}
// Collect basic guest metadata first (no IP yet)
type GuestBase = {
name: string;
vmid: number;
type: "qemu" | "lxc";
node: string;
status: string;
};
const guestBases: GuestBase[] = [];
for (const r of resources) {
const type = r.type as string;
if (type !== "qemu" && type !== "lxc") continue;
if (r.template) continue;
const node = r.node as string;
if (!isSafeNodeName(node)) {
proxmoxLogger.warn("Skipping guest with unsafe node name", {
operation: "proxmox_discover",
node,
vmid: r.vmid,
});
continue;
}
guestBases.push({
name: (r.name as string) || String(r.vmid),
vmid: Number(r.vmid),
type: type as "qemu" | "lxc",
node,
status: (r.status as string) || "unknown",
});
}
// Resolve IPs for all guests in parallel — keeps total time near
// max(single_resolution) instead of sum(all_resolutions).
async function resolveIp(g: GuestBase): Promise<string | null> {
if (g.type === "lxc") {
try {
const cfgJson = await execCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
8000,
);
return parseLxcIp(JSON.parse(cfgJson), preferredPrefixes);
} catch {
return null;
}
}
if (g.type === "qemu" && g.status === "running") {
try {
const ifJson = await execCommand(
client,
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
5000,
);
const data = JSON.parse(ifJson);
const ifaces: Array<Record<string, unknown>> = Array.isArray(
data?.result,
)
? data.result
: Array.isArray(data)
? data
: [];
const allIps: string[] = [];
for (const iface of ifaces) {
if (iface.name === "lo") continue;
const addrs =
(iface["ip-addresses"] as Array<Record<string, string>>) ??
[];
for (const a of addrs) {
if (
a["ip-address-type"] === "ipv4" &&
!a["ip-address"].startsWith("127.")
) {
allIps.push(a["ip-address"]);
}
}
}
if (allIps.length) {
for (const prefix of preferredPrefixes) {
const match = allIps.find((ip) => ip.startsWith(prefix));
if (match) return match;
}
return allIps[0];
}
} catch {
// Guest agent absent or timed out
}
}
return null;
}
// Resolve IPs with bounded concurrency: each lookup opens an SSH exec
// channel, and OpenSSH's default MaxSessions is 10. Capping the number
// of in-flight channels keeps discovery reliable on large clusters.
const CONCURRENCY = 6;
const ips: (string | null)[] = new Array(guestBases.length).fill(null);
let cursor = 0;
async function ipWorker() {
while (cursor < guestBases.length) {
const i = cursor++;
ips[i] = await resolveIp(guestBases[i]);
}
}
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () =>
ipWorker(),
),
);
const guests = guestBases.map((g, i) => ({
...g,
ip: ips[i],
connectionType: matchesAny(g.name, windowsPatterns) ? "rdp" : "ssh",
enableDocker: matchesAny(g.name, dockerPatterns),
}));
proxmoxLogger.info("Proxmox discovery completed", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
guestCount: guests.length,
});
// Return guests with connection type + docker flag, plus credential info
// so the frontend can pre-populate auth for imported hosts.
return res.json({
guests,
credentialId: hostCredentialId,
defaultCredentialId: proxmoxDefaultCredentialId,
});
} finally {
try {
client.end();
} catch {
// ignore cleanup errors
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
proxmoxLogger.error("Proxmox discovery failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
if (
message.includes("pvesh not found") ||
message.includes("Authentication failed") ||
message.includes("connect ECONNREFUSED") ||
message.includes("connect ETIMEDOUT")
) {
return res.status(422).json({ error: `Discovery failed: ${message}` });
}
return res.status(500).json({ error: `Discovery failed: ${message}` });
}
},
);
export default router;
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import path from "path";
// Stub db, logger, fs, and AuthManager before importing the route module
const mockSelect = vi.fn();
const mockDelete = vi.fn();
const mockInsert = vi.fn();
vi.mock("../db/index.js", () => ({
db: {
select: mockSelect,
delete: mockDelete,
insert: mockInsert,
},
}));
vi.mock("../../utils/logger.js", () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
}));
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
const mockReadFile = vi.fn();
const mockStat = vi.fn();
const mockUnlink = vi.fn();
const mockExistsSync = vi.fn();
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>();
return {
...actual,
promises: { readFile: mockReadFile, unlink: mockUnlink },
existsSync: mockExistsSync,
statSync: mockStat,
};
});
// Build a chainable drizzle-like query stub
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = [
"from",
"leftJoin",
"where",
"orderBy",
"limit",
"set",
"values",
];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
Promise.resolve(resolveValue).then(cb);
(chain as unknown as Promise<unknown>).catch = (
cb: (e: unknown) => unknown,
) => Promise.resolve(resolveValue).catch(cb);
return chain;
}
describe("session-log-routes", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.DATA_DIR = "/data";
});
describe("GET / - list logs", () => {
it("returns logs for the authenticated user with file size", async () => {
const rows = [
{
id: 1,
hostId: 10,
userId: "u1",
startedAt: "2026-01-01T00:00:00Z",
endedAt: "2026-01-01T00:05:00Z",
duration: 300,
recordingPath: "/data/session_logs/u1/abc.log",
hostName: "my-server",
hostIp: "10.0.0.1",
},
];
const chain = makeChain(rows);
mockSelect.mockReturnValue(chain);
mockStat.mockReturnValue({ size: 4096 });
// Directly call the route handler extracted from the module
const { default: router } = await import("./session-log-routes.js");
expect(router).toBeDefined();
});
});
describe("path traversal guard", () => {
it("rejects paths outside the allowed session_logs directory", () => {
const allowedBase = path.resolve("/data", "session_logs");
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
expect(malicious.startsWith(allowedBase)).toBe(false);
});
it("allows a legitimate session log path", () => {
const allowedBase = path.resolve("/data", "session_logs");
const valid = path.resolve("/data/session_logs/user1/abc.log");
expect(valid.startsWith(allowedBase)).toBe(true);
});
});
describe("formatters (pure logic)", () => {
it("stat returns size when file exists", () => {
mockExistsSync.mockReturnValue(true);
mockStat.mockReturnValue({ size: 1234 });
const exists = mockExistsSync("/some/file.log");
const { size } = mockStat("/some/file.log");
expect(exists).toBe(true);
expect(size).toBe(1234);
});
});
});
@@ -0,0 +1,316 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import fs from "fs";
import path from "path";
import { eq, and, desc, lt } from "drizzle-orm";
import { db } from "../db/index.js";
import { sessionRecordings, hosts } from "../db/schema.js";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
// Delete session log files and DB rows older than this many days
const LOG_RETENTION_DAYS = 30;
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
).toISOString();
const old = await db
.select({
id: sessionRecordings.id,
recordingPath: sessionRecordings.recordingPath,
})
.from(sessionRecordings)
.where(lt(sessionRecordings.startedAt, cutoff));
for (const row of old) {
if (row.recordingPath) {
const resolved = path.resolve(row.recordingPath);
const allowed = path.resolve(DATA_DIR, "session_logs");
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
await fs.promises.unlink(resolved).catch(() => {});
}
}
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.id, row.id));
}
if (old.length > 0) {
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
operation: "session_log_prune",
count: old.length,
});
}
} catch (err) {
apiLogger.warn("Failed to prune old session logs", {
operation: "session_log_prune_error",
error: err instanceof Error ? err.message : String(err),
});
}
}
// Run prune once at startup, then every 24 hours
pruneOldLogs();
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000);
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
/**
* @openapi
* /session_logs:
* get:
* summary: List session logs
* description: Returns all terminal session recordings for the authenticated user.
* tags:
* - Session Logs
* responses:
* 200:
* description: List of session recordings.
* 500:
* description: Failed to fetch session logs.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select({
id: sessionRecordings.id,
hostId: sessionRecordings.hostId,
userId: sessionRecordings.userId,
startedAt: sessionRecordings.startedAt,
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
const records = rows.map((row) => {
let sizeBytes: number | null = null;
if (row.recordingPath) {
try {
sizeBytes = fs.statSync(row.recordingPath).size;
} catch {
// file may have been removed
}
}
return { ...row, sizeBytes };
});
res.json({ logs: records });
} catch (error) {
apiLogger.error("Failed to fetch session logs", error, {
operation: "session_logs_list",
userId,
});
res.status(500).json({ error: "Failed to fetch session logs" });
}
});
/**
* @openapi
* /session_logs/{id}:
* get:
* summary: Get session log metadata
* description: Returns metadata for a single session recording.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session recording metadata.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to fetch session log.
*/
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select()
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
res.json({ log: rows[0] });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
operation: "session_log_get",
userId,
id,
});
res.status(500).json({ error: "Failed to fetch session log" });
}
});
/**
* @openapi
* /session_logs/{id}/content:
* get:
* summary: Get session log content
* description: Returns the raw text content of a session log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Raw log text.
* 403:
* description: Not authorized.
* 404:
* description: Session log or file not found.
* 500:
* description: Failed to read session log.
*/
router.get(
"/:id/content",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id)
? req.params.id[0]
: req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(
and(
eq(sessionRecordings.id, id),
eq(sessionRecordings.userId, userId),
),
)
.limit(1);
if (rows.length === 0)
return res.status(404).json({ error: "Not found" });
const filePath = rows[0].recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (!resolvedPath.startsWith(allowedBase)) {
return res.status(403).json({ error: "Forbidden" });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath, "utf-8");
res.type("text/plain").send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
userId,
id,
});
res.status(500).json({ error: "Failed to read session log" });
}
},
);
/**
* @openapi
* /session_logs/{id}:
* delete:
* summary: Delete session log
* description: Deletes a session recording and its log file.
* tags:
* - Session Logs
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200:
* description: Session log deleted.
* 403:
* description: Not authorized.
* 404:
* description: Session log not found.
* 500:
* description: Failed to delete session log.
*/
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const id = parseInt(rawId, 10);
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
try {
const rows = await db
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
const filePath = rows[0].recordingPath;
await db
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
if (filePath) {
const resolvedPath = path.resolve(filePath);
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
await fs.promises.unlink(resolvedPath).catch(() => {});
}
}
res.json({ success: true });
} catch (error) {
apiLogger.error("Failed to delete session log", error, {
operation: "session_log_delete",
userId,
id,
});
res.status(500).json({ error: "Failed to delete session log" });
}
});
export default router;
+55
View File
@@ -14,6 +14,7 @@ import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const router = express.Router();
@@ -1158,6 +1159,24 @@ router.post(
name,
});
const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req);
const scActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: scActor[0]?.username ?? userId,
action: "create_snippet",
resourceType: "snippet",
resourceId: String(result[0].id),
resourceName: name,
ipAddress: scIp,
userAgent: scUa,
success: true,
});
res.status(201).json(result[0]);
} catch (err) {
authLogger.error("Failed to create snippet", err);
@@ -1274,6 +1293,24 @@ router.put(
snippetId: parseInt(id),
});
const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req);
const suActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: suActor[0]?.username ?? userId,
action: "update_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
ipAddress: suIp,
userAgent: suUa,
success: true,
});
res.json(updated[0]);
} catch (err) {
authLogger.error("Failed to update snippet", err);
@@ -1340,6 +1377,24 @@ router.delete(
snippetId: parseInt(id),
});
const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req);
const sdActor = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: sdActor[0]?.username ?? userId,
action: "delete_snippet",
resourceType: "snippet",
resourceId: id,
resourceName: existing[0].name,
ipAddress: sdIp,
userAgent: sdUa,
success: true,
});
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete snippet", err);
@@ -0,0 +1,452 @@
import type {
AuthenticatedRequest,
OIDCProviderConfig,
} from "../../../types/index.js";
import type { Router } from "express";
import { db } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import { eq, asc } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { SSOProviderType } from "../../../types/index.js";
const authManager = AuthManager.getInstance();
function decryptProviderConfig(
configJson: string,
_userId: string,
): Record<string, unknown> {
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch {
return {};
}
for (const field of ["client_secret", "bindPassword"] as const) {
const val = config[field] as string | undefined;
if (val?.startsWith("encoded:")) {
try {
config[field] = Buffer.from(val.substring(8), "base64").toString(
"utf8",
);
} catch {
config[field] = "[ENCODING ERROR]";
}
}
}
return config;
}
function encryptProviderConfig(
config: Record<string, unknown>,
_userId: string,
_providerId: string,
): string {
const encoded: Record<string, unknown> = { ...config };
if (
typeof config.client_secret === "string" &&
!config.client_secret.startsWith("encoded:")
) {
encoded.client_secret = `encoded:${Buffer.from(config.client_secret).toString("base64")}`;
}
if (
typeof config.bindPassword === "string" &&
!config.bindPassword.startsWith("encoded:")
) {
encoded.bindPassword = `encoded:${Buffer.from(config.bindPassword).toString("base64")}`;
}
return JSON.stringify(encoded);
}
function applyProviderDefaults(
type: SSOProviderType,
config: Partial<OIDCProviderConfig>,
): Partial<OIDCProviderConfig> {
if (type === "github") {
return {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://github.com",
identifier_path: "id",
name_path: "name",
scopes: "read:user user:email",
userinfo_url: "https://api.github.com/user",
...config,
};
}
if (type === "google") {
return {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
...config,
};
}
return config;
}
export function registerSSOProviderRoutes(router: Router): void {
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
/**
* @openapi
* /users/sso-providers:
* get:
* summary: List enabled SSO providers (public)
* description: Returns public info for all enabled SSO providers for the login page.
* tags:
* - SSO
* responses:
* 200:
* description: Array of public SSO provider objects.
*/
router.get("/sso-providers", async (_req, res) => {
try {
const providers = await db
.select({
id: ssoProviders.id,
name: ssoProviders.name,
type: ssoProviders.type,
displayOrder: ssoProviders.displayOrder,
})
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
res.json(providers);
} catch (err) {
authLogger.error("Failed to list SSO providers", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers/admin:
* get:
* summary: List all SSO providers (admin)
* description: Returns full SSO provider list with decrypted configs for the admin panel.
* tags:
* - SSO
* responses:
* 200:
* description: Array of full SSO provider objects with decrypted config.
*/
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const rows = await db
.select()
.from(ssoProviders)
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
const result = rows.map((row) => ({
...row,
config: decryptProviderConfig(row.config, userId),
}));
res.json(result);
} catch (err) {
authLogger.error("Failed to list SSO providers (admin)", err);
res.status(500).json({ error: "Failed to list SSO providers" });
}
});
/**
* @openapi
* /users/sso-providers:
* post:
* summary: Create SSO provider
* description: Creates a new SSO provider configuration.
* tags:
* - SSO
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* responses:
* 201:
* description: Provider created.
* 400:
* description: Validation error.
*/
router.post("/sso-providers", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const {
name,
type,
enabled = true,
displayOrder = 0,
config: rawConfig = {},
} = req.body as {
name: string;
type: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
if (!name || typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "Provider name is required" });
}
const validTypes: SSOProviderType[] = [
"oidc",
"ldap",
"github",
"google",
];
if (!validTypes.includes(type)) {
return res.status(400).json({ error: "Invalid provider type" });
}
const configWithDefaults =
type === "github" || type === "google"
? applyProviderDefaults(
type,
rawConfig as Partial<OIDCProviderConfig>,
)
: rawConfig;
if (type === "oidc" || type === "github" || type === "google") {
const c = configWithDefaults as Partial<OIDCProviderConfig>;
const missing = [
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
].filter((f) => !c[f as keyof OIDCProviderConfig]);
if (missing.length > 0 && type === "oidc") {
return res.status(400).json({
error: `Missing required OIDC fields: ${missing.join(", ")}`,
});
}
if (
(type === "github" || type === "google") &&
(!c.client_id || !c.client_secret)
) {
return res
.status(400)
.json({ error: "Client ID and Client Secret are required" });
}
}
if (type === "ldap") {
const c = configWithDefaults as Record<string, unknown>;
const missing = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
].filter((f) => !c[f]);
if (missing.length > 0) {
return res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
}
}
const tempId = `new-${Date.now()}`;
const encryptedConfig = encryptProviderConfig(
configWithDefaults as Record<string, unknown>,
userId,
tempId,
);
const [inserted] = await db
.insert(ssoProviders)
.values({
name: name.trim(),
type,
enabled,
displayOrder,
config: encryptedConfig,
})
.returning();
authLogger.info("SSO provider created", {
operation: "sso_provider_create",
userId,
type,
providerId: inserted.id,
});
res.status(201).json({
...inserted,
config: decryptProviderConfig(inserted.config, userId),
});
} catch (err) {
authLogger.error("Failed to create SSO provider", err);
res.status(500).json({ error: "Failed to create SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* put:
* summary: Update SSO provider
* description: Updates an existing SSO provider configuration.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider updated.
* 404:
* description: Provider not found.
*/
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (existing.length === 0) {
return res.status(404).json({ error: "SSO provider not found" });
}
const {
name,
type,
enabled,
displayOrder,
config: rawConfig,
} = req.body as {
name?: string;
type?: SSOProviderType;
enabled?: boolean;
displayOrder?: number;
config?: Record<string, unknown>;
};
let encryptedConfig = existing[0].config;
if (rawConfig !== undefined) {
const existingDecrypted = decryptProviderConfig(
existing[0].config,
userId,
);
const mergedConfig = {
...JSON.parse(
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
),
...rawConfig,
};
encryptedConfig = encryptProviderConfig(
mergedConfig,
userId,
String(providerId),
);
}
const [updated] = await db
.update(ssoProviders)
.set({
...(name !== undefined ? { name: name.trim() } : {}),
...(type !== undefined ? { type } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(displayOrder !== undefined ? { displayOrder } : {}),
config: encryptedConfig,
updatedAt: new Date().toISOString(),
})
.where(eq(ssoProviders.id, providerId))
.returning();
authLogger.info("SSO provider updated", {
operation: "sso_provider_update",
userId,
providerId,
});
res.json({
...updated,
config: decryptProviderConfig(updated.config, userId),
});
} catch (err) {
authLogger.error("Failed to update SSO provider", err);
res.status(500).json({ error: "Failed to update SSO provider" });
}
});
/**
* @openapi
* /users/sso-providers/{id}:
* delete:
* summary: Delete SSO provider
* description: Deletes an SSO provider. Blocked if users are associated.
* tags:
* - SSO
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Provider deleted.
* 409:
* description: Users are associated with this provider.
* 404:
* description: Provider not found.
*/
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const providerId = parseInt(req.params.id as string, 10);
if (isNaN(providerId)) {
return res.status(400).json({ error: "Invalid provider ID" });
}
try {
const existing = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (existing.length === 0) {
return res.status(404).json({ error: "SSO provider not found" });
}
const associatedUsers = db.$client
.prepare(
"SELECT COUNT(*) as count FROM users WHERE sso_provider_id = ?",
)
.get(providerId) as { count: number };
if (associatedUsers.count > 0) {
return res.status(409).json({
error: `Cannot delete provider: ${associatedUsers.count} user(s) are associated with it`,
});
}
await db.delete(ssoProviders).where(eq(ssoProviders.id, providerId));
authLogger.info("SSO provider deleted", {
operation: "sso_provider_delete",
userId,
providerId,
});
res.json({ message: "SSO provider deleted" });
} catch (err) {
authLogger.error("Failed to delete SSO provider", err);
res.status(500).json({ error: "Failed to delete SSO provider" });
}
});
}
export { decryptProviderConfig, encryptProviderConfig };
@@ -0,0 +1,115 @@
import { Router } from "express";
import type { RequestHandler, Router as ExpressRouter } from "express";
import { db } from "../db/index.js";
import { apiLogger } from "../../utils/logger.js";
import { getProxyAgent } from "../../utils/proxy-agent.js";
interface TailscaleDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
}
interface TailscaleAPIDevice {
id: string;
name: string;
hostname: string;
addresses: string[];
os: string;
lastSeen: string;
nodeId?: string;
}
const TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
const router = Router();
export function registerTailscaleRoutes(
app: ExpressRouter,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /tailscale/devices:
* get:
* summary: List Tailscale devices
* description: Returns the list of devices in the configured tailnet using the stored API key.
* tags:
* - Tailscale
* responses:
* 200:
* description: List of tailnet devices.
* content:
* application/json:
* schema:
* type: object
* properties:
* devices:
* type: array
* items:
* type: object
* 500:
* description: Failed to fetch Tailscale devices.
*/
router.get("/devices", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
.get() as { value: string } | undefined;
const apiKey = row?.value ?? "";
if (!apiKey) {
return res.json({ devices: [], hasApiKey: false });
}
const url = `${TAILSCALE_API_BASE}/tailnet/-/devices?fields=all`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
"User-Agent": "Termix/1.0",
},
dispatcher: getProxyAgent(url),
});
if (!response.ok) {
apiLogger.warn("Tailscale API returned non-OK status", {
operation: "tailscale_devices",
status: response.status,
});
if (response.status === 401 || response.status === 403) {
return res
.status(401)
.json({ error: "Invalid Tailscale API key", devices: [] });
}
return res
.status(502)
.json({ error: "Tailscale API error", devices: [] });
}
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
id: d.id,
name: d.name,
hostname: d.hostname,
addresses: d.addresses ?? [],
os: d.os,
lastSeen: d.lastSeen,
}));
res.json({ devices, hasApiKey: true });
} catch (err) {
apiLogger.error("Failed to fetch Tailscale devices", err, {
operation: "tailscale_devices",
});
res
.status(500)
.json({ error: "Failed to fetch Tailscale devices", devices: [] });
}
});
app.use("/tailscale", router);
}
+31 -1
View File
@@ -1,7 +1,7 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { commandHistory } from "../db/schema.js";
import { commandHistory, hosts } from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
@@ -88,6 +88,36 @@ router.post(
});
}
const globalEnabledRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
)
.get() as { value: string } | undefined;
if (globalEnabledRow && globalEnabledRow.value === "false") {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
const hostRecord = await db
.select({ enableCommandHistory: hosts.enableCommandHistory })
.from(hosts)
.where(eq(hosts.id, parseInt(hostId, 10)))
.limit(1);
if (hostRecord.length > 0 && hostRecord[0].enableCommandHistory === false) {
return res.status(201).json({
id: 0,
userId,
hostId: parseInt(hostId, 10),
command: trimmedCommand,
executedAt: new Date().toISOString(),
});
}
try {
const insertData = {
userId,
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
@@ -37,10 +38,19 @@ export function registerUserAdminRoutes(
username: users.username,
isAdmin: users.isAdmin,
isOidc: users.isOidc,
passwordHash: users.passwordHash,
})
.from(users);
res.json({ users: allUsers });
res.json({
users: allUsers.map((u) => ({
userId: u.id,
username: u.username,
is_admin: u.isAdmin,
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
})),
});
} catch (err) {
authLogger.error("Failed to list users", err);
res.status(500).json({ error: "Failed to list users" });
@@ -150,6 +160,25 @@ export function registerUserAdminRoutes(
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: adminUserRecord[0]?.username ?? userId,
action: "make_admin",
resourceType: "user",
resourceId: targetUser[0].id,
resourceName: targetUser[0].username,
ipAddress,
userAgent,
success: true,
});
res.json({ message: `User ${targetUser[0].username} is now an admin` });
} catch (err) {
authLogger.error("Failed to make user admin", err);
@@ -265,6 +294,25 @@ export function registerUserAdminRoutes(
targetUserId: targetUser[0].id,
targetUsername: targetUser[0].username,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const adminUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: adminUserRecord[0]?.username ?? userId,
action: "remove_admin",
resourceType: "user",
resourceId: targetUser[0].id,
resourceName: targetUser[0].username,
ipAddress,
userAgent,
success: true,
});
res.json({
message: `Admin status removed from ${targetUser[0].username}`,
});
@@ -1,4 +1,5 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@@ -6,6 +7,7 @@ import { eq } from "drizzle-orm";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { apiKeys, users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
export function registerUserApiKeyRoutes(
router: Router,
@@ -106,6 +108,29 @@ export function registerUserApiKeyRoutes(
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
await logAudit({
userId: actorId,
username: actorRecord[0]?.username ?? actorId,
action: "create_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: name.trim(),
details: JSON.stringify({
targetUserId,
targetUsername: targetUser[0].username,
}),
ipAddress,
userAgent,
success: true,
});
return res.status(201).json({
id: keyId,
name: name.trim(),
@@ -207,6 +232,25 @@ export function registerUserApiKeyRoutes(
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
const actorId = (req as AuthenticatedRequest).userId;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
await logAudit({
userId: actorId,
username: actorRecord[0]?.username ?? actorId,
action: "delete_api_key",
resourceType: "api_key",
resourceId: keyId,
resourceName: existing[0].name,
ipAddress,
userAgent,
success: true,
});
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete API key", err, {
@@ -11,7 +11,7 @@ vi.mock("../../utils/logger.js", () => ({
},
}));
const { isOIDCUserAllowed, getOIDCConfigFromEnv } =
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
await import("./user-oidc-utils.js");
describe("isOIDCUserAllowed", () => {
@@ -132,3 +132,53 @@ describe("getOIDCConfigFromEnv", () => {
expect(config?.scopes).toBe("openid");
});
});
describe("extractOidcGroups", () => {
it("reads the standard groups claim as an array", () => {
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
"admin",
"user",
]);
});
it("splits a comma-separated string claim", () => {
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
"admin",
"user",
]);
});
it("falls back through groups, roles, then group", () => {
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
});
it("reads a custom claim path when provided", () => {
const userInfo = {
"zitadel:grants:groups:123": ["user", "admin"],
groups: ["ignored"],
};
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
"user",
"admin",
]);
});
it("uses object keys as group names (Zitadel roles object)", () => {
const userInfo = {
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
};
expect(
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
).toEqual(["admin", "user"]);
});
it("falls back to defaults when the custom claim is absent", () => {
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
"admin",
]);
});
it("returns an empty array when no groups are present", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
+179 -1
View File
@@ -1,4 +1,9 @@
import { authLogger } from "../../utils/logger.js";
import type { SSOProviderType } from "../../../types/index.js";
import { db } from "../db/index.js";
import { ssoProviders } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
export type OIDCConfig = {
client_id: string;
@@ -12,6 +17,7 @@ export type OIDCConfig = {
scopes: string;
allowed_users: string;
admin_group: string;
group_claim?: string;
};
export function getOIDCConfigFromEnv(): OIDCConfig | null {
@@ -43,9 +49,46 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
scopes: process.env.OIDC_SCOPES || "openid email profile",
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
admin_group: process.env.OIDC_ADMIN_GROUP || "",
group_claim: process.env.OIDC_GROUP_CLAIM || "",
};
}
/**
* Extracts the list of group/role names from an OIDC userInfo payload.
*
* When `groupClaim` is set, that claim is read first (useful for providers like
* Zitadel that nest roles under a custom path such as
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
* and `group` claims are tried. Values may be an array, a comma-separated
* string, or an object whose keys are the group names.
*/
export function extractOidcGroups(
userInfo: Record<string, unknown>,
groupClaim?: string,
): string[] {
let raw: unknown;
if (groupClaim && groupClaim.trim()) {
raw = userInfo[groupClaim.trim()];
}
if (raw === undefined || raw === null) {
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
}
if (Array.isArray(raw)) {
return raw.map(String);
}
if (typeof raw === "string") {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
if (raw && typeof raw === "object") {
return Object.keys(raw as Record<string, unknown>);
}
return [];
}
export function isOIDCUserAllowed(
allowedUsers: string,
identifier: string,
@@ -53,7 +96,7 @@ export function isOIDCUserAllowed(
): boolean {
if (!allowedUsers || !allowedUsers.trim()) return true;
const patterns = allowedUsers
.split(",")
.split(/[\n,]/)
.map((p) => p.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
@@ -170,3 +213,138 @@ export async function verifyOIDCToken(
return payload;
}
function decryptConfigSecret(
config: Record<string, unknown>,
): Record<string, unknown> {
const out = { ...config };
for (const field of ["client_secret", "bindPassword"] as const) {
const val = out[field] as string | undefined;
if (val?.startsWith("encoded:")) {
try {
out[field] = Buffer.from(val.substring(8), "base64").toString("utf8");
} catch {
// leave as-is
}
} else if (val?.startsWith("encrypted:")) {
// encrypted: prefix means it was encrypted with DataCrypto; without a
// userId/dataKey here we cannot decrypt it. The caller should use the
// full admin decrypt path when possible. Fall back to stripping prefix.
try {
out[field] = Buffer.from(val.substring(10), "base64").toString("utf8");
} catch {
// leave as-is
}
}
}
return out;
}
export async function loadProviderConfig(
providerId: number | null | undefined,
adminUserId?: string,
): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
if (providerId != null) {
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.id, providerId))
.limit(1);
if (rows.length > 0) {
const row = rows[0];
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
parsed = {};
}
if (adminUserId) {
try {
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
if (adminDataKey) {
parsed = DataCrypto.decryptRecord(
"settings",
parsed,
adminUserId,
adminDataKey,
);
}
} catch {
parsed = decryptConfigSecret(parsed);
}
} else {
parsed = decryptConfigSecret(parsed);
}
const config = parsed as unknown as OIDCConfig;
return {
config,
providerType: row.type as SSOProviderType,
providerDbId: row.id,
};
}
} catch (err) {
authLogger.error("Failed to load SSO provider config by id", err, {
providerId,
});
}
}
// Fallback: env vars
const envConfig = getOIDCConfigFromEnv();
if (envConfig) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
// Fallback: first enabled OIDC-type provider in ssoProviders table
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true))
.orderBy();
const oidcRow = rows.find(
(r) => r.type === "oidc" || r.type === "github" || r.type === "google",
);
if (oidcRow) {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(oidcRow.config);
} catch {
parsed = {};
}
parsed = decryptConfigSecret(parsed);
return {
config: parsed as unknown as OIDCConfig,
providerType: oidcRow.type as SSOProviderType,
providerDbId: oidcRow.id,
};
}
} catch {
// fall through to legacy
}
// Fallback: legacy settings blob
try {
const legacyRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get() as { value: string } | undefined;
if (legacyRow) {
let config = JSON.parse(legacyRow.value) as Record<string, unknown>;
config = decryptConfigSecret(config);
return {
config: config as unknown as OIDCConfig,
providerType: "oidc",
providerDbId: null,
};
}
} catch {
// no legacy config
}
return null;
}
@@ -123,7 +123,7 @@ export function registerUserPasswordResetRoutes(
);
authLogger.info(
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
`Password reset code generated for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
);
res.json({
+156 -8
View File
@@ -17,6 +17,17 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
fontSize: row?.fontSize ?? null,
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
storageMode: row?.storageMode ?? "local",
commandAutocomplete: row?.commandAutocomplete ?? null,
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
showHostTags: row?.showHostTags ?? null,
hostTrayOnClick: row?.hostTrayOnClick ?? null,
pinAppRail: row?.pinAppRail ?? null,
foldersCollapsed: row?.foldersCollapsed ?? null,
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
});
/**
@@ -36,6 +47,51 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
* properties:
* reopenTabsOnLogin:
* type: boolean
* theme:
* type: string
* nullable: true
* fontSize:
* type: string
* nullable: true
* accentColor:
* type: string
* nullable: true
* language:
* type: string
* nullable: true
* storageMode:
* type: string
* nullable: true
* commandAutocomplete:
* type: boolean
* nullable: true
* commandPaletteEnabled:
* type: boolean
* nullable: true
* showHostTags:
* type: boolean
* nullable: true
* hostTrayOnClick:
* type: boolean
* nullable: true
* pinAppRail:
* type: boolean
* nullable: true
* foldersCollapsed:
* type: boolean
* nullable: true
* confirmSnippetExecution:
* type: boolean
* nullable: true
* disableUpdateCheck:
* type: boolean
* nullable: true
* confirmTabClose:
* type: boolean
* nullable: true
* hiddenRailTabs:
* type: string
* nullable: true
*/
router.get("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -72,20 +128,77 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
* properties:
* reopenTabsOnLogin:
* type: boolean
* theme:
* type: string
* fontSize:
* type: string
* accentColor:
* type: string
* language:
* type: string
* storageMode:
* type: string
* commandAutocomplete:
* type: boolean
* commandPaletteEnabled:
* type: boolean
* showHostTags:
* type: boolean
* hostTrayOnClick:
* type: boolean
* pinAppRail:
* type: boolean
* foldersCollapsed:
* type: boolean
* confirmSnippetExecution:
* type: boolean
* disableUpdateCheck:
* type: boolean
* confirmTabClose:
* type: boolean
* hiddenRailTabs:
* type: string
* responses:
* 200:
* description: Preferences updated successfully.
*/
router.put("/", authenticateJWT, (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { reopenTabsOnLogin, theme, fontSize, accentColor, language } =
req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
};
const {
reopenTabsOnLogin,
theme,
fontSize,
accentColor,
language,
storageMode,
commandAutocomplete,
commandPaletteEnabled,
showHostTags,
hostTrayOnClick,
pinAppRail,
foldersCollapsed,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
fontSize?: string | null;
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
pinAppRail?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
};
const updates: Partial<typeof userPreferences.$inferInsert> = {
updatedAt: new Date().toISOString(),
@@ -105,16 +218,51 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
fontSize,
accentColor,
language,
storageMode,
hiddenRailTabs,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
showHostTags,
hostTrayOnClick,
pinAppRail,
foldersCollapsed,
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
};
for (const [key, value] of Object.entries(boolFields)) {
if (value !== undefined && value !== null && typeof value !== "boolean") {
return res.status(400).json({ error: `${key} must be a boolean` });
}
}
if (theme !== undefined) updates.theme = theme;
if (fontSize !== undefined) updates.fontSize = fontSize;
if (accentColor !== undefined) updates.accentColor = accentColor;
if (language !== undefined) updates.language = language;
if (storageMode !== undefined) updates.storageMode = storageMode;
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
if (commandAutocomplete !== undefined)
updates.commandAutocomplete = commandAutocomplete;
if (commandPaletteEnabled !== undefined)
updates.commandPaletteEnabled = commandPaletteEnabled;
if (showHostTags !== undefined) updates.showHostTags = showHostTags;
if (hostTrayOnClick !== undefined) updates.hostTrayOnClick = hostTrayOnClick;
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
if (foldersCollapsed !== undefined)
updates.foldersCollapsed = foldersCollapsed;
if (confirmSnippetExecution !== undefined)
updates.confirmSnippetExecution = confirmSnippetExecution;
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
@@ -5,6 +5,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { db } from "../db/index.js";
import { sessions, users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
type UserSessionRoutesDeps = {
authenticateJWT: RequestHandler;
@@ -166,6 +167,25 @@ export function registerUserSessionRoutes(
revokedBy: userId,
sessionUserId: session.userId,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorUser[0]?.username ?? userId,
action: "revoke_session",
resourceType: "session",
resourceId: sessionId,
details: JSON.stringify({ targetUserId: session.userId }),
ipAddress,
userAgent,
success: true,
});
res.json({ success: true, message: "Session revoked successfully" });
} else {
res.status(500).json({ error: "Failed to revoke session" });
@@ -244,6 +264,30 @@ export function registerUserSessionRoutes(
revokedCount,
});
const { ipAddress, userAgent } = getRequestMeta(req);
const actorUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
const targetUserRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, revokeUserId))
.limit(1);
await logAudit({
userId,
username: actorUser[0]?.username ?? userId,
action: "revoke_all_sessions",
resourceType: "session",
resourceId: revokeUserId,
resourceName: targetUserRecord[0]?.username,
details: JSON.stringify({ revokedCount, exceptCurrent }),
ipAddress,
userAgent,
success: true,
});
res.json({
message: `${revokedCount} session(s) revoked successfully`,
count: revokedCount,
@@ -9,6 +9,7 @@ import {
} from "../../utils/logger.js";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
function getDefaultGuacUrl(): string {
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
@@ -122,6 +123,24 @@ export function registerUserSettingsRoutes(
const urlRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
.get() as { value: string } | undefined;
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_guacamole_settings",
resourceType: "setting",
details: JSON.stringify({ enabled, url }),
ipAddress,
userAgent,
success: true,
});
res.json({
enabled: enabledRow ? enabledRow.value !== "false" : true,
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
@@ -194,6 +213,24 @@ export function registerUserSettingsRoutes(
)
.run(level);
setGlobalLogLevel(level);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_log_level",
resourceType: "setting",
details: JSON.stringify({ level }),
ipAddress,
userAgent,
success: true,
});
res.json({ level });
} catch (err) {
authLogger.error("Failed to set log level", err);
@@ -267,10 +304,244 @@ export function registerUserSettingsRoutes(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
)
.run(String(timeoutHours));
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_session_timeout",
resourceType: "setting",
details: JSON.stringify({ timeoutHours }),
ipAddress,
userAgent,
success: true,
});
res.json({ timeoutHours });
} catch (err) {
authLogger.error("Failed to set session timeout", err);
res.status(500).json({ error: "Failed to set session timeout" });
}
});
/**
* @openapi
* /users/tailscale-settings:
* get:
* summary: Get Tailscale settings
* description: Returns whether a Tailscale API key is configured (value is masked).
* tags:
* - Users
* responses:
* 200:
* description: Tailscale settings.
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* description: Masked API key or empty string if not set.
* hasApiKey:
* type: boolean
*/
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
.get() as { value: string } | undefined;
const apiKey = row?.value ?? "";
res.json({
apiKey: apiKey
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
: "",
hasApiKey: !!apiKey,
});
} catch (err) {
authLogger.error("Failed to get Tailscale settings", err);
res.status(500).json({ error: "Failed to get Tailscale settings" });
}
});
/**
* @openapi
* /users/tailscale-settings:
* patch:
* summary: Update Tailscale settings (admin only)
* description: Saves or clears the Tailscale API key used for device discovery.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* responses:
* 200:
* description: Tailscale settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update Tailscale settings.
*/
router.patch("/tailscale-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { apiKey } = req.body;
if (typeof apiKey !== "string") {
return res.status(400).json({ error: "apiKey must be a string" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('tailscale_api_key', ?)",
)
.run(apiKey);
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_tailscale_settings",
resourceType: "setting",
details: JSON.stringify({ hasApiKey: !!apiKey }),
ipAddress,
userAgent,
success: true,
});
res.json({ hasApiKey: !!apiKey });
} catch (err) {
authLogger.error("Failed to update Tailscale settings", err);
res.status(500).json({ error: "Failed to update Tailscale settings" });
}
});
/**
* @openapi
* /users/command-history-enabled:
* get:
* summary: Get command history enabled setting
* description: Returns whether command history recording is globally enabled.
* tags:
* - Users
* responses:
* 200:
* description: Command history enabled status.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
*/
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
)
.get() as { value: string } | undefined;
res.json({ enabled: row ? row.value !== "false" : true });
} catch (err) {
authLogger.error("Failed to get command history enabled setting", err);
res
.status(500)
.json({ error: "Failed to get command history enabled setting" });
}
});
/**
* @openapi
* /users/command-history-enabled:
* patch:
* summary: Update command history enabled setting (admin only)
* description: Globally enables or disables command history recording for all hosts.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update setting.
*/
router.patch(
"/command-history-enabled",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
db.$client
.prepare(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('command_history_enabled', ?)",
)
.run(enabled ? "true" : "false");
const { ipAddress, userAgent } = getRequestMeta(req);
const actorRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: actorRecord[0]?.username ?? userId,
action: "update_command_history_enabled",
resourceType: "setting",
details: JSON.stringify({ enabled }),
ipAddress,
userAgent,
success: true,
});
res.json({ enabled });
} catch (err) {
authLogger.error(
"Failed to update command history enabled setting",
err,
);
res
.status(500)
.json({ error: "Failed to update command history enabled setting" });
}
},
);
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
import speakeasy from "speakeasy";
// The route module imports the db barrel (which has filesystem/crypto side
// effects on import) plus the logger; stub both so importing stays inert.
const updateWhere = vi.fn().mockResolvedValue(undefined);
const updateSet = vi.fn(() => ({ where: updateWhere }));
const dbUpdate = vi.fn(() => ({ set: updateSet }));
vi.mock("../db/index.js", () => ({
db: {
update: dbUpdate,
},
}));
vi.mock("../../utils/logger.js", () => ({
authLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { verifyTotpReauth } = await import("./user-totp-routes.js");
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
const secret = speakeasy.generateSecret({ name: "test" }).base32;
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
return {
id: "user-1",
isOidc: false,
passwordHash: bcrypt.hashSync("correct-horse", 4),
totpSecret: secret,
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
totpEnabled: true,
...overrides,
} as AnyUser;
}
describe("verifyTotpReauth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("accepts the correct password", async () => {
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true);
});
it("accepts a valid TOTP code without a password", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
});
it("accepts a valid backup code and consumes it", async () => {
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
expect(result).toBe(true);
expect(updateSet).toHaveBeenCalledWith({
totpBackupCodes: JSON.stringify(["BACKUP02"]),
});
});
it("rejects a wrong password / invalid code", async () => {
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
expect(updateSet).not.toHaveBeenCalled();
});
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
const token = speakeasy.totp({ secret, encoding: "base32" });
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
});
it("handles malformed backup-code JSON without throwing", async () => {
const user = makeUser({ totpBackupCodes: "not json" });
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
});
});
+74 -36
View File
@@ -1,6 +1,6 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Router } from "express";
import { eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
@@ -23,6 +23,57 @@ interface UserTotpRoutesDeps {
isNativeAppRequest: NativeAppRequestChecker;
}
type TotpUserRecord = typeof users.$inferSelect;
export async function verifyTotpReauth(
userRecord: TotpUserRecord,
credential: string,
): Promise<boolean> {
if (!userRecord.isOidc && userRecord.passwordHash) {
const passwordMatch = await bcrypt.compare(
credential,
userRecord.passwordHash,
);
if (passwordMatch) {
return true;
}
}
if (userRecord.totpSecret) {
const totpMatch = speakeasy.totp.verify({
secret: userRecord.totpSecret,
encoding: "base32",
token: credential,
window: 2,
});
if (totpMatch) {
return true;
}
}
let backupCodes: unknown = [];
try {
backupCodes = userRecord.totpBackupCodes
? JSON.parse(userRecord.totpBackupCodes)
: [];
} catch {
backupCodes = [];
}
if (Array.isArray(backupCodes)) {
const backupIndex = backupCodes.indexOf(credential);
if (backupIndex !== -1) {
backupCodes.splice(backupIndex, 1);
await db
.update(users)
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userRecord.id));
return true;
}
}
return false;
}
export function registerUserTotpRoutes(
router: Router,
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
@@ -113,6 +164,7 @@ export function registerUserTotpRoutes(
*/
router.post("/totp/enable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const sessionId = (req as AuthenticatedRequest).sessionId;
const { totp_code } = req.body;
if (!totp_code) {
@@ -158,7 +210,13 @@ export function registerUserTotpRoutes(
})
.where(eq(users.id, userId));
await db.delete(sessions).where(eq(sessions.userId, userId));
await db
.delete(sessions)
.where(
sessionId
? and(eq(sessions.userId, userId), ne(sessions.id, sessionId))
: eq(sessions.userId, userId),
);
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
try {
@@ -219,11 +277,12 @@ export function registerUserTotpRoutes(
router.post("/totp/disable", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
const credential = password || totp_code;
if (!password || !totp_code) {
if (!credential) {
return res
.status(400)
.json({ error: "Both password and TOTP code are required" });
.json({ error: "A TOTP code or password is required" });
}
try {
@@ -238,22 +297,11 @@ export function registerUserTotpRoutes(
return res.status(400).json({ error: "TOTP is not enabled" });
}
if (!userRecord.isOidc) {
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
}
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
});
const verified = await verifyTotpReauth(userRecord, credential);
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
return res
.status(401)
.json({ error: "Incorrect password or invalid TOTP code" });
}
await db
@@ -310,11 +358,12 @@ export function registerUserTotpRoutes(
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password, totp_code } = req.body;
const credential = password || totp_code;
if (!password || !totp_code) {
if (!credential) {
return res
.status(400)
.json({ error: "Both password and TOTP code are required" });
.json({ error: "A TOTP code or password is required" });
}
try {
@@ -329,22 +378,11 @@ export function registerUserTotpRoutes(
return res.status(400).json({ error: "TOTP is not enabled" });
}
if (!userRecord.isOidc) {
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
}
const verified = speakeasy.totp.verify({
secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
});
const verified = await verifyTotpReauth(userRecord, credential);
if (!verified) {
return res.status(401).json({ error: "Invalid TOTP code" });
return res
.status(401)
.json({ error: "Incorrect password or invalid TOTP code" });
}
const backupCodes = Array.from({ length: 8 }, () =>
+374 -72
View File
@@ -20,6 +20,8 @@ import {
getOIDCConfigFromEnv,
isOIDCUserAllowed,
verifyOIDCToken,
extractOidcGroups,
loadProviderConfig,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
@@ -29,6 +31,9 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const authManager = AuthManager.getInstance();
@@ -225,6 +230,20 @@ router.post("/create", async (req, res) => {
username,
isAdmin: isFirstUser,
});
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId: id,
username,
action: "create_user",
resourceType: "user",
resourceId: id,
resourceName: username,
ipAddress,
userAgent,
success: true,
});
res.json({
message: "User created",
is_admin: isFirstUser,
@@ -272,6 +291,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
scopes,
allowed_users,
admin_group,
group_claim,
} = req.body;
const isDisableRequest =
@@ -331,6 +351,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
scopes: scopes || "openid email profile",
allowed_users: allowed_users || "",
admin_group: admin_group || "",
group_claim: group_claim || "",
};
let encryptedConfig;
@@ -440,35 +461,19 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
* 500:
* description: Failed to get OIDC config.
*/
router.get("/oidc-config", async (req, res) => {
router.get("/oidc-config", async (_req, res) => {
try {
const envConfig = getOIDCConfigFromEnv();
if (envConfig) {
return res.json({
client_id: envConfig.client_id,
issuer_url: envConfig.issuer_url,
authorization_url: envConfig.authorization_url,
scopes: envConfig.scopes,
});
}
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get();
if (!row) {
const providerResult = await loadProviderConfig(undefined);
if (!providerResult) {
return res.json(null);
}
const config = JSON.parse((row as Record<string, unknown>).value as string);
const publicConfig = {
const { config } = providerResult;
return res.json({
client_id: config.client_id,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
scopes: config.scopes,
};
return res.json(publicConfig);
});
} catch (err) {
authLogger.error("Failed to get OIDC config", err);
res.status(500).json({ error: "Failed to get OIDC config" });
@@ -569,24 +574,25 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
*/
router.get("/oidc/authorize", async (req, res) => {
try {
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
const {
rememberMe,
desktopCallbackPort,
appCallbackUrl,
providerId: providerIdStr,
} = req.query;
const origin = getRequestOriginWithForceHTTPS(req);
const backendCallbackUri = `${origin}/users/oidc/callback`;
const envConfig = getOIDCConfigFromEnv();
let config;
if (envConfig) {
config = envConfig;
} else {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get();
if (!row) {
return res.status(404).json({ error: "OIDC not configured" });
}
config = JSON.parse((row as Record<string, unknown>).value as string);
const resolvedProviderId = providerIdStr
? parseInt(providerIdStr as string, 10)
: null;
const providerResult = await loadProviderConfig(
resolvedProviderId || undefined,
);
if (!providerResult) {
return res.status(404).json({ error: "OIDC not configured" });
}
const { config, providerDbId } = providerResult;
const state = nanoid();
const nonce = nanoid();
@@ -631,6 +637,12 @@ router.get("/oidc/authorize", async (req, res) => {
rememberMe === "true" ? "true" : "false",
);
if (providerDbId != null) {
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`oidc_provider_${state}`, String(providerDbId));
}
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set("client_id", config.client_id);
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
@@ -699,33 +711,260 @@ router.get("/oidc/callback", async (req, res) => {
return res.status(400).json({ error: "Invalid state parameter" });
}
const envConfig = getOIDCConfigFromEnv();
let config;
const storedProviderIdRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`oidc_provider_${state}`) as { value: string } | null;
const callbackProviderId = storedProviderIdRow
? parseInt(storedProviderIdRow.value, 10)
: null;
if (envConfig) {
config = envConfig;
} else {
const configRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get();
if (!configRow) {
return res.status(500).json({ error: "OIDC not configured" });
}
config = JSON.parse(
(configRow as Record<string, unknown>).value as string,
);
const providerResult = await loadProviderConfig(
callbackProviderId || undefined,
);
if (!providerResult) {
return res.status(500).json({ error: "OIDC not configured" });
}
const {
config,
providerType: callbackProviderType,
providerDbId: callbackProviderDbId,
} = providerResult;
if (config.client_secret?.startsWith("encrypted:")) {
config.client_secret = Buffer.from(
config.client_secret.substring(10),
"base64",
).toString("utf8");
} else if (config.client_secret?.startsWith("encoded:")) {
config.client_secret = Buffer.from(
config.client_secret.substring(8),
"base64",
).toString("utf8");
// Clean up provider state key
if (storedProviderIdRow) {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_provider_${state}`);
}
// GitHub does not issue OIDC id_tokens; handle its token exchange separately
if (callbackProviderType === "github") {
const ghTokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
redirect_uri: backendCallbackUri,
}),
});
if (!ghTokenResponse.ok) {
const errorText = await ghTokenResponse.text();
authLogger.error("GitHub token exchange failed", {
operation: "github_token_exchange_failed",
status: ghTokenResponse.status,
errorResponse: errorText,
});
return res
.status(400)
.json({ error: "Failed to exchange authorization code" });
}
const ghTokenData = (await ghTokenResponse.json()) as Record<
string,
unknown
>;
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_state_${state}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_backend_callback_${state}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_frontend_origin_${state}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_remember_me_${state}`);
const ghUserInfoResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${ghTokenData.access_token}`,
Accept: "application/json",
"User-Agent": "Termix",
},
});
if (!ghUserInfoResponse.ok) {
return res
.status(400)
.json({ error: "Failed to get GitHub user information" });
}
const ghUserInfo = (await ghUserInfoResponse.json()) as Record<
string,
unknown
>;
const emailRes = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${ghTokenData.access_token}`,
Accept: "application/json",
"User-Agent": "Termix",
},
});
if (emailRes.ok) {
const emails = (await emailRes.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primary = emails.find((e) => e.primary && e.verified);
if (primary) ghUserInfo.email = primary.email;
}
const ghIdentifier = `github:${callbackProviderDbId}:${String(ghUserInfo.id ?? ghUserInfo.login)}`;
const ghName = (ghUserInfo.name ||
ghUserInfo.login ||
ghIdentifier) as string;
const deviceInfo = parseUserAgent(req);
let ghUser = await db
.select()
.from(users)
.where(eq(users.oidcIdentifier, ghIdentifier));
if (!ghUser || ghUser.length === 0) {
const preCheckCount = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number };
const isFirstUser = (preCheckCount?.count || 0) === 0;
if (!isFirstUser && config.allowed_users) {
const email = ghUserInfo.email as string | undefined;
if (!isOIDCUserAllowed(config.allowed_users, ghIdentifier, email)) {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "user_not_allowed");
return res.redirect(redirectUrl.toString());
}
}
let ghAutoProvision = false;
try {
const r = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
)
.get() as { value: string } | undefined;
if (r) ghAutoProvision = r.value === "true";
} catch {
/* */
}
if (!ghAutoProvision)
ghAutoProvision =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
if (!isFirstUser && !ghAutoProvision) {
const regRow = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'allow_registration'",
)
.get() as { value: string } | undefined;
if (regRow && regRow.value !== "true") {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
}
}
const ghId = nanoid();
const ghIsFirst = db.$client.transaction(() => {
const c =
(
db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get() as { count?: number }
)?.count || 0;
const first = c === 0;
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)",
)
.run(
ghId,
ghName,
"",
first ? 1 : 0,
ghIdentifier,
callbackProviderDbId,
);
return first;
})();
try {
const defaultRoleName = ghIsFirst ? "admin" : "user";
const defaultRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, defaultRoleName))
.limit(1);
if (defaultRole.length > 0)
await db.insert(userRoles).values({
userId: ghId,
roleId: defaultRole[0].id,
grantedBy: ghId,
});
} catch {
/* */
}
try {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
await authManager.registerOIDCUser(ghId, sessionDurationMs);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, ghId));
return res.status(500).json({
error: "Failed to setup user security - user creation cancelled",
});
}
ghUser = await db.select().from(users).where(eq(users.id, ghId));
}
const ghUserRecord = ghUser[0];
try {
await authManager.authenticateOIDCUser(
ghUserRecord.id,
deviceInfo.type,
);
} catch {
/* */
}
const ghToken = await authManager.generateJWTToken(ghUserRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: storedRememberMe,
});
const ghRedirectUrl = new URL(frontendOrigin);
ghRedirectUrl.searchParams.set("success", "true");
const ghIsTokenCallback =
frontendOrigin.startsWith("http://127.0.0.1:") ||
frontendOrigin.startsWith("termix-mobile:");
const ghMaxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
: storedRememberMe
? 30 * 24 * 60 * 60 * 1000
: 24 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
if (ghIsTokenCallback) {
ghRedirectUrl.searchParams.set("token", ghToken);
return res.redirect(ghRedirectUrl.toString());
}
return res
.cookie(
"jwt",
ghToken,
authManager.getSecureCookieOptions(req, ghMaxAge),
)
.redirect(ghRedirectUrl.toString());
}
const tokenResponse = await fetch(config.token_url, {
@@ -733,6 +972,7 @@ router.get("/oidc/callback", async (req, res) => {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.client_id)}:${encodeURIComponent(config.client_secret)}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
@@ -834,7 +1074,7 @@ router.get("/oidc/callback", async (req, res) => {
}
}
if (!userInfo && tokenData.access_token) {
if (tokenData.access_token) {
for (const userInfoUrl of userInfoUrls) {
try {
const userInfoResponse = await fetch(userInfoUrl, {
@@ -844,10 +1084,11 @@ router.get("/oidc/callback", async (req, res) => {
});
if (userInfoResponse.ok) {
userInfo = (await userInfoResponse.json()) as Record<
const fetchedUserInfo = (await userInfoResponse.json()) as Record<
string,
unknown
>;
userInfo = { ...userInfo, ...fetchedUserInfo };
break;
} else {
authLogger.error(
@@ -986,7 +1227,7 @@ router.get("/oidc/callback", async (req, res) => {
const first = (countResult?.count || 0) === 0;
db.$client
.prepare(
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(
id,
@@ -995,14 +1236,7 @@ router.get("/oidc/callback", async (req, res) => {
first ? 1 : 0,
1,
identifier,
String(config.client_id),
String(config.client_secret),
String(config.issuer_url),
String(config.authorization_url),
String(config.token_url),
String(config.identifier_path),
String(config.name_path),
String(config.scopes),
callbackProviderDbId,
);
return first;
})();
@@ -1107,9 +1341,27 @@ router.get("/oidc/callback", async (req, res) => {
// Sync admin status based on OIDC group membership
if (config.admin_group) {
const groups = (userInfo.groups || userInfo.roles || []) as string[];
const groups = extractOidcGroups(
userInfo as Record<string, unknown>,
config.group_claim,
);
authLogger.info(
`Evaluating OIDC admin group sync. parsedGroups: ${JSON.stringify(groups)}, configuredAdminGroup: ${config.admin_group}, groupClaim: ${config.group_claim || "(default)"}, availableUserInfoKeys: ${Object.keys(userInfo).join(",")}`,
{
operation: "oidc_admin_group_sync_eval",
userId: userRecord.id,
},
);
const shouldBeAdmin = groups.includes(config.admin_group);
if (!!userRecord.isAdmin !== shouldBeAdmin) {
authLogger.info("Syncing admin status based on OIDC group membership", {
operation: "oidc_admin_group_sync",
userId: userRecord.id,
group: config.admin_group,
isAdmin: shouldBeAdmin,
});
await db
.update(users)
.set({ isAdmin: shouldBeAdmin })
@@ -1411,6 +1663,17 @@ router.post("/login", async (req, res) => {
sessionId: payload?.sessionId,
});
const { ipAddress: loginIp, userAgent: loginUa } = getRequestMeta(req);
await logAudit({
userId: userRecord.id,
username,
action: "login",
resourceType: "session",
ipAddress: loginIp,
userAgent: loginUa,
success: true,
});
const response: Record<string, unknown> = {
success: true,
is_admin: !!userRecord.isAdmin,
@@ -2097,6 +2360,23 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
userId,
});
const { ipAddress: pwIp, userAgent: pwUa } = getRequestMeta(req);
const pwUser = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: pwUser[0]?.username ?? userId,
action: "change_password",
resourceType: "user",
resourceId: userId,
ipAddress: pwIp,
userAgent: pwUa,
success: true,
});
res.json({ message: "Password changed successfully. Please log in again." });
});
@@ -2184,6 +2464,25 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
targetUserId,
targetUsername: username,
});
const { ipAddress: deleteIp, userAgent: deleteUa } = getRequestMeta(req);
const delAdminRecord = await db
.select({ username: users.username })
.from(users)
.where(eq(users.id, userId))
.limit(1);
await logAudit({
userId,
username: delAdminRecord[0]?.username ?? userId,
action: "delete_user",
resourceType: "user",
resourceId: targetUserId,
resourceName: username,
ipAddress: deleteIp,
userAgent: deleteUa,
success: true,
});
res.json({ message: `User ${username} deleted successfully` });
} catch (err) {
authLogger.error("Failed to delete user", err);
@@ -2222,4 +2521,7 @@ registerUserSettingsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerSSOProviderRoutes(router);
registerLDAPAuthRoutes(router);
export default router;
@@ -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) };
}),
);
}

Some files were not shown because too many files have changed in this diff Show More