From 9de904dd4c31d4e0538891e9c02c0a31d6a3b512 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:28:26 -0500 Subject: [PATCH] release-2.5.0 (#994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: fix ci checks (#966) * Fix dashboard service link creation (#950) * Fix jump host SOCKS5 proxy selection (#951) * Fix jump host SOCKS5 proxy selection * fix: type jump host socks proxy config * Fix tmux detection path handling (#949) * Support GUACD_URL environment config (#952) * Retry autostart tunnel host fetches (#953) * Retry autostart tunnel host fetches * chore: format tunnel route * Fix PUID html ownership in Docker entrypoint (#954) * feat: allow custom tunnel endpoints (#977) * fix: skip metrics start for non-ssh hosts (#976) * Fix Proxmox import auth fallback (#956) * Fix Proxmox import auth fallback * chore: format proxmox import auth * Fix SSH heading syntax highlighting (#955) * Fix SSH heading syntax highlighting * chore: format terminal highlighter * Initialize auth before fullscreen terminal routes (#957) * Add WebAuthn passkey authentication (#959) * chore: add Biome tooling (#965) * chore: add biome tooling * chore: support tailwind syntax in biome * Fix VNC required argument handshake (#968) * Prioritize host results in command palette search (#969) * Prevent sidebar host hover layout shift (#970) * Add terminal font zoom with mouse wheel (#971) * Add host temperature metrics card (#972) * Make file downloads reliable in desktop app (#973) * Add app rail hover expansion setting (#974) * fix: use correct translation key for nav.close (#964) * fix(tunnel): skip endpoint credential validation for direct tunnels (#963) * fix: SSH port connection bug (#975) * fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948) * feat: support SSH agent auth across SSH features (#960) * feat: add Podman container runtime support (#958) * chore: update readme and release notes * fix: stabilize Windows app icon (#978) * Add safe host sharing export (#979) * Add external editor support for file manager (#985) * Rework SSH credential password handling (#984) * Support password fallback for SSH key credentials * Complete SSH credential password fallback * Fix runtime base path for auth callbacks (#982) * Fix TUI terminal output highlighting (#983) Co-authored-by: LukeGus * Add app fullscreen mode (#993) * Fix host metrics startup polling (#986) * chore: update release notes * fix: line chart text overlap * chore: update RELEASE_NOTES.md * chore: add donation goal to readme * chore: update readme * fix: hide full-screen button in electron app * fix: failed unit tests * chore: lint, format, and bump version to 2.5.0 * chore: remove timeout from crowdin translate * chore: sync Crowdin translations for 2.5.0 --------- Signed-off-by: dependabot[bot] Co-authored-by: DivByZero Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh Co-authored-by: sash Co-authored-by: ZacharyZcR --- .github/workflows/dependabot-retarget.yml | 2 +- .github/workflows/docker.yml | 2 +- .github/workflows/donation-goal.yml | 126 + .github/workflows/electron.yml | 14 +- .github/workflows/openapi.yml | 2 +- .github/workflows/pr-check.yml | 2 +- .github/workflows/release.yml | 18 +- README.md | 49 +- RELEASE_NOTES.md | 170 +- biome.json | 58 + docker/Dockerfile | 8 +- docker/entrypoint.sh | 4 +- docker/nginx-https.conf | 75 + docker/nginx.conf | 75 + electron-builder.json | 7 +- electron/main.cjs | 312 +- electron/preload.js | 20 + package-lock.json | 1969 ++++-- package.json | 42 +- readme/HOST-TO-HOST-TRANSFER.md | 185 - readme/README-AR.md | 49 +- readme/README-CN.md | 49 +- readme/README-DE.md | 49 +- readme/README-ES.md | 49 +- readme/README-FR.md | 49 +- readme/README-HI.md | 49 +- readme/README-IT.md | 49 +- readme/README-JA.md | 49 +- readme/README-KO.md | 49 +- readme/README-PT.md | 49 +- readme/README-RU.md | 49 +- readme/README-TR.md | 49 +- readme/README-VI.md | 49 +- repo-images/Image 15.png | Bin 0 -> 539961 bytes repo-images/Image 16.png | Bin 0 -> 76082 bytes repo-images/donation-goal.svg | 8 + scripts/crowdin-pretranslate.cjs | 3 +- scripts/patch-guacamole-lite.cjs | 187 +- scripts/patch-guacamole-lite.test.ts | 23 + scripts/publish-youtube.cjs | 3 +- src/backend/database/database.ts | 6 + src/backend/database/db/index.ts | 417 +- src/backend/database/db/schema.ts | 276 +- .../database/routes/acme-ssl-routes.ts | 17 +- .../database/routes/alert-rules-routes.ts | 660 ++ .../routes/credential-deploy-routes.ts | 160 +- src/backend/database/routes/credentials.ts | 10 +- .../routes/dashboard-service-links-routes.ts | 25 +- .../routes/homepage-favicon-routes.ts | 103 + .../database/routes/homepage-items-routes.ts | 225 + .../database/routes/homepage-layout-routes.ts | 110 + .../database/routes/homepage-ping-routes.ts | 127 + .../database/routes/homepage-proxy-routes.ts | 100 + .../database/routes/homepage-rss-routes.ts | 148 + .../database/routes/host-bulk-routes.ts | 123 +- .../database/routes/host-normalizers.test.ts | 10 + .../database/routes/host-normalizers.ts | 10 +- src/backend/database/routes/host.ts | 317 +- src/backend/database/routes/proxmox.ts | 10 + .../database/routes/service-link-url.test.ts | 28 + .../database/routes/service-link-url.ts | 16 + src/backend/database/routes/snippets.ts | 13 +- .../database/routes/ssh-certificate.test.ts | 127 + .../database/routes/ssh-certificate.ts | 145 + .../database/routes/termix-id-keys.test.ts | 95 + src/backend/database/routes/termix-id-keys.ts | 89 + src/backend/database/routes/termix-id.test.ts | 147 + src/backend/database/routes/termix-id.ts | 1550 +++++ .../routes/user-password-reset-routes.ts | 4 + .../database/routes/user-preferences.ts | 11 + .../database/routes/user-settings-routes.ts | 12 +- .../database/routes/user-webauthn-routes.ts | 514 ++ src/backend/database/routes/users.ts | 73 +- src/backend/database/routes/vault.ts | 450 ++ src/backend/guacamole/guacamole-server.ts | 20 +- src/backend/guacamole/routes.ts | 11 +- src/backend/guacamole/token-service.test.ts | 48 + src/backend/guacamole/token-service.ts | 2 + src/backend/homepage.ts | 35 + src/backend/serial/serial.ts | 204 + src/backend/ssh/alert-engine.ts | 380 ++ src/backend/ssh/container-runtime.ts | 43 + src/backend/ssh/docker-console.ts | 53 +- src/backend/ssh/docker-container-routes.ts | 43 +- src/backend/ssh/docker.ts | 132 +- .../ssh/file-manager-content-routes.ts | 265 + src/backend/ssh/file-manager-list-routes.ts | 6 + src/backend/ssh/file-manager-utils.test.ts | 45 + src/backend/ssh/file-manager-utils.ts | 37 + src/backend/ssh/file-manager.ts | 49 +- src/backend/ssh/host-metrics-helpers.test.ts | 1 + .../ssh/host-metrics-history-routes.ts | 125 + src/backend/ssh/host-metrics-jump-hosts.ts | 29 +- .../ssh/host-metrics-preferences.test.ts | 2 +- .../ssh/host-metrics-settings-routes.ts | 90 + src/backend/ssh/host-metrics.ts | 185 +- src/backend/ssh/host-resolver.ts | 24 +- src/backend/ssh/jump-host-chain.ts | 29 +- src/backend/ssh/jump-host-proxy.ts | 51 + src/backend/ssh/managers/health.ts | 31 +- src/backend/ssh/managers/index.ts | 4 + src/backend/ssh/managers/tailscale.ts | 177 + src/backend/ssh/managers/validation.ts | 17 + src/backend/ssh/managers/wireguard.ts | 223 + src/backend/ssh/terminal-agent-auth.test.ts | 135 + src/backend/ssh/terminal-auth-helpers.ts | 50 + src/backend/ssh/terminal-jump-hosts.ts | 29 +- src/backend/ssh/terminal.ts | 418 +- src/backend/ssh/tmux-helper.test.ts | 20 + src/backend/ssh/tmux-helper.ts | 31 +- src/backend/ssh/tmux-monitor.ts | 69 +- src/backend/ssh/tunnel.ts | 283 +- src/backend/ssh/vault-oidc-auth.ts | 216 + src/backend/ssh/vault-signer-auth.ts | 167 + src/backend/ssh/vault-signer-core.test.ts | 268 + src/backend/ssh/vault-signer-core.ts | 265 + src/backend/ssh/widgets/network-collector.ts | 38 +- .../ssh/widgets/temperature-collector.test.ts | 33 + .../ssh/widgets/temperature-collector.ts | 117 + src/backend/starter.ts | 2 + src/backend/swagger.ts | 4 + src/backend/utils/alert-trigger.ts | 29 + src/backend/utils/auth-manager.ts | 25 + src/backend/utils/field-crypto.ts | 2 + src/backend/utils/guacd-config.ts | 86 + src/backend/utils/logger.ts | 1 + src/backend/utils/notification-sender.ts | 139 + src/backend/utils/request-origin.test.ts | 108 + src/backend/utils/request-origin.ts | 48 + .../utils/shared-credential-manager.ts | 90 + src/backend/utils/simple-db-ops.ts | 1 + src/backend/utils/ssh-key-utils.test.ts | 27 + src/backend/utils/ssh-key-utils.ts | 57 +- src/backend/utils/user-crypto.ts | 113 + src/main.tsx | 81 +- src/types/electron.d.ts | 28 + src/types/homepage-types.ts | 417 ++ src/types/host-metrics.ts | 8 +- src/types/index.ts | 46 +- src/types/stats-widgets.ts | 15 +- src/types/ui-types.ts | 54 +- src/ui/AppShell.tsx | 307 +- src/ui/api/alerts-api.ts | 133 + src/ui/api/credentials-api.ts | 2 +- src/ui/api/dashboard-api.ts | 14 +- src/ui/api/homepage-api.ts | 81 + src/ui/api/host-metrics-api.ts | 37 + src/ui/api/open-tabs-api.ts | 1 + src/ui/api/ssh-file-operations-api.ts | 104 +- src/ui/api/ssh-host-management-api.ts | 22 +- src/ui/api/termix-id-api.ts | 217 + src/ui/api/vault-profiles-api.ts | 60 + src/ui/api/webauthn-api.ts | 119 + src/ui/auth/Auth.tsx | 22 + src/ui/auth/ElectronLoginForm.tsx | 61 +- src/ui/auth/LoginPage.tsx | 124 +- src/ui/components/charts/LineChart.tsx | 292 + .../proxmox/ProxmoxDiscoverDialog.tsx | 12 +- .../proxmox/proxmox-import-auth.test.ts | 30 + .../components/proxmox/proxmox-import-auth.ts | 33 + src/ui/dashboard/DashboardTab.tsx | 513 +- .../dashboard/cards/HomepagePreviewCard.tsx | 36 + src/ui/dashboard/cards/NetworkGraphCard.tsx | 4 +- src/ui/features/file-manager/FileManager.tsx | 251 +- .../components/DraggableWindow.tsx | 6 +- .../file-manager/components/FileViewer.tsx | 30 + .../file-manager/components/FileWindow.tsx | 115 + .../components/PermissionsDialog.tsx | 2 +- .../components/TerminalWindow.tsx | 22 + .../file-manager/file-manager-types.ts | 1 + src/ui/features/guacamole/GuacamoleApp.tsx | 147 +- .../guacamole/GuacamoleDisplay.test.ts | 68 + .../features/guacamole/GuacamoleDisplay.tsx | 98 +- .../guacamole/guacamole-websocket-url.ts | 31 + src/ui/features/homepage/HomepageApp.tsx | 12 + src/ui/features/homepage/HomepageCanvas.tsx | 571 ++ .../homepage/canvas/CanvasDotBackground.tsx | 63 + .../homepage/canvas/canvasGeometry.test.ts | 80 + .../homepage/canvas/canvasGeometry.ts | 39 + .../homepage/canvas/snapToGrid.test.ts | 39 + src/ui/features/homepage/canvas/snapToGrid.ts | 9 + src/ui/features/homepage/canvas/useBoxDrag.ts | 66 + .../features/homepage/canvas/useBoxResize.ts | 82 + .../homepage/canvas/useCanvasInput.ts | 85 + .../homepage/dialogs/AddWidgetMenu.tsx | 150 + .../homepage/dialogs/AlertFeedEditForm.tsx | 46 + .../homepage/dialogs/BookmarkListEditForm.tsx | 65 + .../homepage/dialogs/CalendarEditForm.tsx | 41 + .../homepage/dialogs/ClockEditForm.tsx | 48 + .../homepage/dialogs/CountdownEditForm.tsx | 57 + .../homepage/dialogs/CustomApiEditForm.tsx | 137 + .../dialogs/DashboardLinksEditForm.tsx | 64 + .../dialogs/DockerActivityEditForm.tsx | 46 + .../homepage/dialogs/DockerWidgetEditForm.tsx | 18 + .../dialogs/FileManagerWidgetEditForm.tsx | 18 + .../homepage/dialogs/FolderEditForm.tsx | 44 + .../homepage/dialogs/HostGridEditForm.tsx | 90 + .../homepage/dialogs/HostStatusEditForm.tsx | 122 + .../homepage/dialogs/IframeEditForm.tsx | 35 + .../homepage/dialogs/ImageWidgetEditForm.tsx | 71 + .../homepage/dialogs/LinkTreeEditForm.tsx | 134 + .../dialogs/MarkdownNotesEditForm.tsx | 76 + .../homepage/dialogs/MetricsChartEditForm.tsx | 110 + .../homepage/dialogs/NotesEditForm.tsx | 25 + .../homepage/dialogs/PingStatusEditForm.tsx | 96 + .../homepage/dialogs/QuickConnectEditForm.tsx | 143 + .../dialogs/RecentActivityEditForm.tsx | 92 + .../homepage/dialogs/RssFeedEditForm.tsx | 56 + .../homepage/dialogs/SearchBarEditForm.tsx | 82 + .../homepage/dialogs/SearchLinksEditForm.tsx | 83 + .../homepage/dialogs/ServiceGridEditForm.tsx | 114 + .../homepage/dialogs/ServiceLinkEditForm.tsx | 101 + .../homepage/dialogs/SingleHostEditForm.tsx | 45 + .../dialogs/SshQuickConnectEditForm.tsx | 105 + .../homepage/dialogs/SshTerminalEditForm.tsx | 33 + .../dialogs/SystemOverviewEditForm.tsx | 49 + .../homepage/dialogs/TermixUptimeEditForm.tsx | 27 + .../homepage/dialogs/TextBannerEditForm.tsx | 142 + .../homepage/dialogs/TunnelWidgetEditForm.tsx | 18 + .../homepage/dialogs/WeatherEditForm.tsx | 59 + .../homepage/dialogs/WidgetEditDialog.tsx | 378 ++ .../homepage/toolbar/HomepageToolbar.tsx | 127 + .../homepage/widgets/AlertFeedWidget.tsx | 150 + .../homepage/widgets/BookmarkListWidget.tsx | 75 + .../homepage/widgets/CalendarWidget.tsx | 164 + .../features/homepage/widgets/ClockWidget.tsx | 65 + .../homepage/widgets/CountdownWidget.tsx | 148 + .../homepage/widgets/CustomApiWidget.tsx | 207 + .../homepage/widgets/DashboardLinksWidget.tsx | 147 + .../homepage/widgets/DockerActivityWidget.tsx | 108 + .../homepage/widgets/DockerWidget.tsx | 94 + .../homepage/widgets/FileManagerWidget.tsx | 86 + .../homepage/widgets/FolderWidget.tsx | 49 + .../homepage/widgets/HostGridWidget.tsx | 144 + .../homepage/widgets/HostStatusWidget.tsx | 336 + .../homepage/widgets/IframeWidget.tsx | 56 + .../features/homepage/widgets/ImageWidget.tsx | 91 + .../homepage/widgets/LinkTreeWidget.tsx | 125 + .../homepage/widgets/MarkdownNotesWidget.tsx | 127 + .../homepage/widgets/MetricsChartWidget.tsx | 250 + .../features/homepage/widgets/NotesWidget.tsx | 39 + .../homepage/widgets/PingStatusWidget.tsx | 138 + .../homepage/widgets/QuickConnectWidget.tsx | 171 + .../homepage/widgets/RecentActivityWidget.tsx | 143 + .../homepage/widgets/RssFeedWidget.tsx | 155 + .../homepage/widgets/SearchBarWidget.tsx | 103 + .../homepage/widgets/SearchLinksWidget.tsx | 129 + .../homepage/widgets/ServiceGridWidget.tsx | 160 + .../homepage/widgets/ServiceLinkWidget.tsx | 131 + .../widgets/SshQuickConnectWidget.tsx | 162 + .../homepage/widgets/SshTerminalWidget.tsx | 169 + .../homepage/widgets/SystemOverviewWidget.tsx | 135 + .../homepage/widgets/TermixUptimeWidget.tsx | 145 + .../homepage/widgets/TextBannerWidget.tsx | 60 + .../homepage/widgets/TunnelWidget.tsx | 122 + .../homepage/widgets/WeatherWidget.tsx | 195 + .../homepage/widgets/WidgetRegistry.ts | 20 + .../features/homepage/widgets/WidgetShell.tsx | 168 + .../features/homepage/widgets/WidgetTitle.tsx | 20 + .../features/host-metrics/HostMetricsTab.tsx | 25 +- .../host-metrics/MetricsHistorySection.tsx | 222 + .../host-metrics/cards/CardTimeTabs.tsx | 34 + .../features/host-metrics/cards/CpuCard.tsx | 126 +- .../features/host-metrics/cards/DiskCard.tsx | 115 +- .../host-metrics/cards/MemoryCard.tsx | 117 +- .../host-metrics/cards/NetworkCard.tsx | 203 +- .../host-metrics/cards/TemperatureCard.tsx | 56 + src/ui/features/host-metrics/cards/index.tsx | 30 +- .../cards/managers/TailscaleManagerCard.tsx | 171 + .../cards/managers/WireGuardManagerCard.tsx | 209 + src/ui/features/serial/Serial.tsx | 381 ++ src/ui/features/serial/serial-types.ts | 6 + src/ui/features/terminal/Terminal.tsx | 504 +- src/ui/features/terminal/TerminalPreview.tsx | 8 +- src/ui/features/terminal/terminal-theme.ts | 8 + src/ui/features/terminal/terminal-types.ts | 1 + src/ui/features/tunnel/TunnelTab.tsx | 11 +- src/ui/features/tunnel/tunnel-endpoints.ts | 25 + src/ui/hooks/use-status-color-scheme.ts | 11 + src/ui/lib/ServerStatusContext.tsx | 4 + src/ui/lib/host-connection-tabs.ts | 40 + src/ui/lib/service-link-url.ts | 16 + .../lib/terminal-syntax-highlighter.test.ts | 21 + src/ui/lib/terminal-syntax-highlighter.ts | 44 +- src/ui/lib/terminal-themes.ts | 28 + src/ui/lib/theme.ts | 6 + src/ui/locales/en.json | 561 +- src/ui/locales/translated/af_ZA.json | 4276 +++++++------ src/ui/locales/translated/ar_SA.json | 5400 ++++++++-------- src/ui/locales/translated/bg_BG.json | 4370 +++++++------ src/ui/locales/translated/bn_BD.json | 4390 +++++++------ src/ui/locales/translated/ca_ES.json | 4230 +++++++------ src/ui/locales/translated/cs_CZ.json | 5384 ++++++++-------- src/ui/locales/translated/da_DK.json | 5208 +++++++++------- src/ui/locales/translated/de_DE.json | 5152 +++++++++------- src/ui/locales/translated/el_GR.json | 5358 ++++++++-------- src/ui/locales/translated/es_ES.json | 5194 +++++++++------- src/ui/locales/translated/fi_FI.json | 5408 +++++++++-------- src/ui/locales/translated/fr_FR.json | 5266 +++++++++------- src/ui/locales/translated/he_IL.json | 4356 +++++++------ src/ui/locales/translated/hi_IN.json | 4372 +++++++------ src/ui/locales/translated/hu_HU.json | 4288 +++++++------ src/ui/locales/translated/id_ID.json | 4234 +++++++------ src/ui/locales/translated/it_IT.json | 5282 ++++++++-------- src/ui/locales/translated/ja_JP.json | 5358 ++++++++-------- src/ui/locales/translated/ko_KR.json | 4352 +++++++------ src/ui/locales/translated/nl_NL.json | 5180 +++++++++------- src/ui/locales/translated/no_NO.json | 5274 +++++++++------- src/ui/locales/translated/pl_PL.json | 5306 ++++++++-------- src/ui/locales/translated/pt_BR.json | 5124 +++++++++------- src/ui/locales/translated/pt_PT.json | 5302 ++++++++-------- src/ui/locales/translated/ro_RO.json | 5252 +++++++++------- src/ui/locales/translated/ru_RU.json | 5392 ++++++++-------- src/ui/locales/translated/sr_SP.json | 4382 +++++++------ src/ui/locales/translated/sv_SE.json | 5294 ++++++++-------- src/ui/locales/translated/th_TH.json | 4382 +++++++------ src/ui/locales/translated/tr_TR.json | 4276 +++++++------ src/ui/locales/translated/uk_UA.json | 5392 ++++++++-------- src/ui/locales/translated/vi_VN.json | 4290 +++++++------ src/ui/locales/translated/zh_CN.json | 5154 +++++++++------- src/ui/locales/translated/zh_TW.json | 4326 +++++++------ src/ui/main-axios.ts | 50 + src/ui/shell/CommandPalette.tsx | 291 +- src/ui/shell/TabBar.tsx | 34 +- src/ui/shell/tabUtils.tsx | 33 + src/ui/sidebar/AdminSettingsPanel.tsx | 29 +- src/ui/sidebar/AdminSettingsSections.tsx | 33 + src/ui/sidebar/AlertRuleDialog.tsx | 274 + src/ui/sidebar/AlertsPanel.tsx | 460 ++ src/ui/sidebar/AppRail.tsx | 73 +- src/ui/sidebar/CredentialEditorView.tsx | 528 +- src/ui/sidebar/HostCredentialList.tsx | 16 + src/ui/sidebar/HostEditor.tsx | 326 +- src/ui/sidebar/HostEditorData.test.ts | 46 +- src/ui/sidebar/HostEditorData.ts | 17 +- src/ui/sidebar/HostEditorFeatureTabs.tsx | 29 + src/ui/sidebar/HostManager.tsx | 13 + src/ui/sidebar/HostManagerData.ts | 12 +- src/ui/sidebar/HostsPanel.tsx | 35 +- src/ui/sidebar/NotificationChannelDialog.tsx | 243 + src/ui/sidebar/SerialPanel.tsx | 352 ++ src/ui/sidebar/SidebarTree.tsx | 53 +- src/ui/sidebar/SnippetsPanel.tsx | 134 + src/ui/sidebar/SshToolsPanel.tsx | 54 +- src/ui/sidebar/TermixIdPanel.tsx | 933 +++ src/ui/sidebar/UserProfilePanel.tsx | 191 + src/ui/sidebar/VaultProfileManager.tsx | 276 + src/ui/ssh/dialogs/SSHAuthDialog.tsx | 14 +- 348 files changed, 125184 insertions(+), 76537 deletions(-) create mode 100644 .github/workflows/donation-goal.yml create mode 100644 biome.json delete mode 100644 readme/HOST-TO-HOST-TRANSFER.md create mode 100644 repo-images/Image 15.png create mode 100644 repo-images/Image 16.png create mode 100644 repo-images/donation-goal.svg create mode 100644 scripts/patch-guacamole-lite.test.ts create mode 100644 src/backend/database/routes/alert-rules-routes.ts create mode 100644 src/backend/database/routes/homepage-favicon-routes.ts create mode 100644 src/backend/database/routes/homepage-items-routes.ts create mode 100644 src/backend/database/routes/homepage-layout-routes.ts create mode 100644 src/backend/database/routes/homepage-ping-routes.ts create mode 100644 src/backend/database/routes/homepage-proxy-routes.ts create mode 100644 src/backend/database/routes/homepage-rss-routes.ts create mode 100644 src/backend/database/routes/service-link-url.test.ts create mode 100644 src/backend/database/routes/service-link-url.ts create mode 100644 src/backend/database/routes/ssh-certificate.test.ts create mode 100644 src/backend/database/routes/ssh-certificate.ts create mode 100644 src/backend/database/routes/termix-id-keys.test.ts create mode 100644 src/backend/database/routes/termix-id-keys.ts create mode 100644 src/backend/database/routes/termix-id.test.ts create mode 100644 src/backend/database/routes/termix-id.ts create mode 100644 src/backend/database/routes/user-webauthn-routes.ts create mode 100644 src/backend/database/routes/vault.ts create mode 100644 src/backend/guacamole/token-service.test.ts create mode 100644 src/backend/homepage.ts create mode 100644 src/backend/serial/serial.ts create mode 100644 src/backend/ssh/alert-engine.ts create mode 100644 src/backend/ssh/container-runtime.ts create mode 100644 src/backend/ssh/host-metrics-history-routes.ts create mode 100644 src/backend/ssh/jump-host-proxy.ts create mode 100644 src/backend/ssh/managers/tailscale.ts create mode 100644 src/backend/ssh/managers/wireguard.ts create mode 100644 src/backend/ssh/terminal-agent-auth.test.ts create mode 100644 src/backend/ssh/tmux-helper.test.ts create mode 100644 src/backend/ssh/vault-oidc-auth.ts create mode 100644 src/backend/ssh/vault-signer-auth.ts create mode 100644 src/backend/ssh/vault-signer-core.test.ts create mode 100644 src/backend/ssh/vault-signer-core.ts create mode 100644 src/backend/ssh/widgets/temperature-collector.test.ts create mode 100644 src/backend/ssh/widgets/temperature-collector.ts create mode 100644 src/backend/utils/alert-trigger.ts create mode 100644 src/backend/utils/guacd-config.ts create mode 100644 src/backend/utils/notification-sender.ts create mode 100644 src/backend/utils/request-origin.test.ts create mode 100644 src/types/homepage-types.ts create mode 100644 src/ui/api/alerts-api.ts create mode 100644 src/ui/api/homepage-api.ts create mode 100644 src/ui/api/termix-id-api.ts create mode 100644 src/ui/api/vault-profiles-api.ts create mode 100644 src/ui/api/webauthn-api.ts create mode 100644 src/ui/components/charts/LineChart.tsx create mode 100644 src/ui/components/proxmox/proxmox-import-auth.test.ts create mode 100644 src/ui/components/proxmox/proxmox-import-auth.ts create mode 100644 src/ui/dashboard/cards/HomepagePreviewCard.tsx create mode 100644 src/ui/features/guacamole/GuacamoleDisplay.test.ts create mode 100644 src/ui/features/guacamole/guacamole-websocket-url.ts create mode 100644 src/ui/features/homepage/HomepageApp.tsx create mode 100644 src/ui/features/homepage/HomepageCanvas.tsx create mode 100644 src/ui/features/homepage/canvas/CanvasDotBackground.tsx create mode 100644 src/ui/features/homepage/canvas/canvasGeometry.test.ts create mode 100644 src/ui/features/homepage/canvas/canvasGeometry.ts create mode 100644 src/ui/features/homepage/canvas/snapToGrid.test.ts create mode 100644 src/ui/features/homepage/canvas/snapToGrid.ts create mode 100644 src/ui/features/homepage/canvas/useBoxDrag.ts create mode 100644 src/ui/features/homepage/canvas/useBoxResize.ts create mode 100644 src/ui/features/homepage/canvas/useCanvasInput.ts create mode 100644 src/ui/features/homepage/dialogs/AddWidgetMenu.tsx create mode 100644 src/ui/features/homepage/dialogs/AlertFeedEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/BookmarkListEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/CalendarEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/ClockEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/CountdownEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/CustomApiEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/DashboardLinksEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/DockerActivityEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/DockerWidgetEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/FileManagerWidgetEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/FolderEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/HostGridEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/HostStatusEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/IframeEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/ImageWidgetEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/LinkTreeEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/MarkdownNotesEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/MetricsChartEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/NotesEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/PingStatusEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/QuickConnectEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/RecentActivityEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/RssFeedEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SearchBarEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SearchLinksEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/ServiceGridEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/ServiceLinkEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SingleHostEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SshQuickConnectEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SshTerminalEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/SystemOverviewEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/TermixUptimeEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/TextBannerEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/TunnelWidgetEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/WeatherEditForm.tsx create mode 100644 src/ui/features/homepage/dialogs/WidgetEditDialog.tsx create mode 100644 src/ui/features/homepage/toolbar/HomepageToolbar.tsx create mode 100644 src/ui/features/homepage/widgets/AlertFeedWidget.tsx create mode 100644 src/ui/features/homepage/widgets/BookmarkListWidget.tsx create mode 100644 src/ui/features/homepage/widgets/CalendarWidget.tsx create mode 100644 src/ui/features/homepage/widgets/ClockWidget.tsx create mode 100644 src/ui/features/homepage/widgets/CountdownWidget.tsx create mode 100644 src/ui/features/homepage/widgets/CustomApiWidget.tsx create mode 100644 src/ui/features/homepage/widgets/DashboardLinksWidget.tsx create mode 100644 src/ui/features/homepage/widgets/DockerActivityWidget.tsx create mode 100644 src/ui/features/homepage/widgets/DockerWidget.tsx create mode 100644 src/ui/features/homepage/widgets/FileManagerWidget.tsx create mode 100644 src/ui/features/homepage/widgets/FolderWidget.tsx create mode 100644 src/ui/features/homepage/widgets/HostGridWidget.tsx create mode 100644 src/ui/features/homepage/widgets/HostStatusWidget.tsx create mode 100644 src/ui/features/homepage/widgets/IframeWidget.tsx create mode 100644 src/ui/features/homepage/widgets/ImageWidget.tsx create mode 100644 src/ui/features/homepage/widgets/LinkTreeWidget.tsx create mode 100644 src/ui/features/homepage/widgets/MarkdownNotesWidget.tsx create mode 100644 src/ui/features/homepage/widgets/MetricsChartWidget.tsx create mode 100644 src/ui/features/homepage/widgets/NotesWidget.tsx create mode 100644 src/ui/features/homepage/widgets/PingStatusWidget.tsx create mode 100644 src/ui/features/homepage/widgets/QuickConnectWidget.tsx create mode 100644 src/ui/features/homepage/widgets/RecentActivityWidget.tsx create mode 100644 src/ui/features/homepage/widgets/RssFeedWidget.tsx create mode 100644 src/ui/features/homepage/widgets/SearchBarWidget.tsx create mode 100644 src/ui/features/homepage/widgets/SearchLinksWidget.tsx create mode 100644 src/ui/features/homepage/widgets/ServiceGridWidget.tsx create mode 100644 src/ui/features/homepage/widgets/ServiceLinkWidget.tsx create mode 100644 src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx create mode 100644 src/ui/features/homepage/widgets/SshTerminalWidget.tsx create mode 100644 src/ui/features/homepage/widgets/SystemOverviewWidget.tsx create mode 100644 src/ui/features/homepage/widgets/TermixUptimeWidget.tsx create mode 100644 src/ui/features/homepage/widgets/TextBannerWidget.tsx create mode 100644 src/ui/features/homepage/widgets/TunnelWidget.tsx create mode 100644 src/ui/features/homepage/widgets/WeatherWidget.tsx create mode 100644 src/ui/features/homepage/widgets/WidgetRegistry.ts create mode 100644 src/ui/features/homepage/widgets/WidgetShell.tsx create mode 100644 src/ui/features/homepage/widgets/WidgetTitle.tsx create mode 100644 src/ui/features/host-metrics/MetricsHistorySection.tsx create mode 100644 src/ui/features/host-metrics/cards/CardTimeTabs.tsx create mode 100644 src/ui/features/host-metrics/cards/TemperatureCard.tsx create mode 100644 src/ui/features/host-metrics/cards/managers/TailscaleManagerCard.tsx create mode 100644 src/ui/features/host-metrics/cards/managers/WireGuardManagerCard.tsx create mode 100644 src/ui/features/serial/Serial.tsx create mode 100644 src/ui/features/serial/serial-types.ts create mode 100644 src/ui/features/tunnel/tunnel-endpoints.ts create mode 100644 src/ui/lib/host-connection-tabs.ts create mode 100644 src/ui/lib/service-link-url.ts create mode 100644 src/ui/sidebar/AlertRuleDialog.tsx create mode 100644 src/ui/sidebar/AlertsPanel.tsx create mode 100644 src/ui/sidebar/NotificationChannelDialog.tsx create mode 100644 src/ui/sidebar/SerialPanel.tsx create mode 100644 src/ui/sidebar/TermixIdPanel.tsx create mode 100644 src/ui/sidebar/VaultProfileManager.tsx diff --git a/.github/workflows/dependabot-retarget.yml b/.github/workflows/dependabot-retarget.yml index 36f904ca..a3edad47 100644 --- a/.github/workflows/dependabot-retarget.yml +++ b/.github/workflows/dependabot-retarget.yml @@ -14,7 +14,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b8fb0e7f..38098726 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -35,7 +35,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/donation-goal.yml b/.github/workflows/donation-goal.yml new file mode 100644 index 00000000..e495594f --- /dev/null +++ b/.github/workflows/donation-goal.yml @@ -0,0 +1,126 @@ +name: Donation Goal Badge + +on: + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Generate donation goal SVG + run: | + set -e + + EVM_ADDRESS="0x83eA1Db55cc6E34fCD11Da2b7849621af67b6E34" + BTC_ADDRESS="bc1qrxc3vpnl6qhh9p8akjmjukcgmgmq852ua64h05" + SOL_ADDRESS="T4BF5ioySVUjwaPNw4Sdu7oK8SXLxgQRcMaTQ6YJ2UJ" + DOCS_SNAPSHOT_URL="https://raw.githubusercontent.com/Termix-SSH/Termix-Docs/main/static/donation-snapshot.json" + MONTHLY_GOAL=750 + + # Fetch crypto prices + PRICES=$(curl -sf "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,bitcoin,solana&vs_currencies=usd" || echo '{}') + ETH_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethereum',{}).get('usd',0))" 2>/dev/null || echo 0) + BTC_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('bitcoin',{}).get('usd',0))" 2>/dev/null || echo 0) + SOL_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('solana',{}).get('usd',0))" 2>/dev/null || echo 0) + + # Fetch live balances + ETH_RAW=$(curl -sf "https://eth.blockscout.com/api/v2/addresses/${EVM_ADDRESS}" || echo '{"coin_balance":"0"}') + ETH_BAL=$(echo "$ETH_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(int(d.get('coin_balance','0')) / 1e18)" 2>/dev/null || echo 0) + + BASE_RAW=$(curl -sf "https://base.blockscout.com/api/v2/addresses/${EVM_ADDRESS}" || echo '{"coin_balance":"0"}') + BASE_BAL=$(echo "$BASE_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(int(d.get('coin_balance','0')) / 1e18)" 2>/dev/null || echo 0) + + BTC_RAW=$(curl -sf "https://blockstream.info/api/address/${BTC_ADDRESS}" || echo '{"chain_stats":{"funded_txo_sum":0,"spent_txo_sum":0}}') + BTC_BAL=$(echo "$BTC_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); s=d.get('chain_stats',{}); print((s.get('funded_txo_sum',0)-s.get('spent_txo_sum',0))/1e8)" 2>/dev/null || echo 0) + + SOL_RAW=$(curl -sf -X POST "https://api.mainnet-beta.solana.com" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"${SOL_ADDRESS}\"]}" || echo '{"result":{"value":0}}') + SOL_BAL=$(echo "$SOL_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('value',0)/1e9)" 2>/dev/null || echo 0) + + # Fetch snapshot baseline from docs repo + SNAPSHOT=$(curl -sf "$DOCS_SNAPSHOT_URL" || echo '{"ethBal":0,"baseBal":0,"btcBal":0,"solBal":0}') + SNAP_ETH=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethBal',0))" 2>/dev/null || echo 0) + SNAP_BASE=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('baseBal',0))" 2>/dev/null || echo 0) + SNAP_BTC=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('btcBal',0))" 2>/dev/null || echo 0) + SNAP_SOL=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('solBal',0))" 2>/dev/null || echo 0) + + export ETH_BAL BASE_BAL BTC_BAL SOL_BAL SNAP_ETH SNAP_BASE SNAP_BTC SNAP_SOL ETH_PRICE BTC_PRICE SOL_PRICE MONTHLY_GOAL + + python3 - <<'PYEOF' + import os, math + + eth_bal = float(os.environ.get('ETH_BAL', 0)) + base_bal = float(os.environ.get('BASE_BAL', 0)) + btc_bal = float(os.environ.get('BTC_BAL', 0)) + sol_bal = float(os.environ.get('SOL_BAL', 0)) + snap_eth = float(os.environ.get('SNAP_ETH', 0)) + snap_base = float(os.environ.get('SNAP_BASE', 0)) + snap_btc = float(os.environ.get('SNAP_BTC', 0)) + snap_sol = float(os.environ.get('SNAP_SOL', 0)) + eth_price = float(os.environ.get('ETH_PRICE', 0)) + btc_price = float(os.environ.get('BTC_PRICE', 0)) + sol_price = float(os.environ.get('SOL_PRICE', 0)) + goal = float(os.environ.get('MONTHLY_GOAL', 750)) + + delta_eth = max(0, eth_bal - snap_eth) + delta_base = max(0, base_bal - snap_base) + delta_btc = max(0, btc_bal - snap_btc) + delta_sol = max(0, sol_bal - snap_sol) + + total = delta_eth * eth_price + delta_base * eth_price + delta_btc * btc_price + delta_sol * sol_price + pct = min(total / goal * 100, 100) if goal > 0 else 0 + + if total < 1: + display = '< $1' + else: + display = f'${math.floor(total):,}' + + goal_display = f'${int(goal):,}' + + # SVG dimensions + W = 400 + BAR_W = 360 + BAR_X = 20 + BAR_H = 8 + FILL_W = round(BAR_W * pct / 100) + + label = f'Monthly Donation Goal {display} / {goal_display}' + pct_label = f'{round(pct)}% of monthly goal' if pct < 100 else 'Goal reached this month. Thank you!' + + svg = f''' + Monthly donation goal + + {label} + + + {pct_label} + ''' + + with open('repo-images/donation-goal.svg', 'w') as f: + f.write(svg) + print(f'SVG written: {display} / {goal_display} ({round(pct)}%)') + PYEOF + - name: Commit SVG + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add repo-images/donation-goal.svg + if git diff --cached --quiet; then + exit 0 + fi + # Amend the previous bot commit if it exists, otherwise create a new one + LAST_AUTHOR=$(git log -1 --format='%ae') + if [ "$LAST_AUTHOR" = "github-actions[bot]@users.noreply.github.com" ]; then + git commit --amend --no-edit + git push --force-with-lease + else + git commit -m "chore: update donation goal badge" + git push + fi diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index e6e6e4ad..c6ac48ed 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -142,7 +142,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -352,7 +352,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -578,7 +578,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -684,7 +684,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -821,7 +821,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -931,7 +931,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 2ea9b852..c76952da 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6c40ebae..2e48e167 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b17cb42..a3940b86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: exit 1 - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -85,7 +85,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 @@ -137,7 +137,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout dev branch - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ needs.prep.outputs.dev_branch }} fetch-depth: 0 @@ -220,7 +220,7 @@ jobs: build_ref: ${{ steps.merge.outputs.build_ref }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 1 @@ -297,7 +297,7 @@ jobs: contents: write steps: - name: Checkout main - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 1 @@ -370,7 +370,7 @@ jobs: contents: write steps: - name: Checkout main - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 1 @@ -409,7 +409,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout Termix - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ needs.merge-to-main.outputs.build_ref }} fetch-depth: 1 @@ -429,7 +429,7 @@ jobs: npm run generate:openapi - name: Checkout Docs repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: Termix-SSH/Docs ref: main @@ -498,7 +498,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout main - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 1 diff --git a/README.md b/README.md index 92c5426f..e1ac7b30 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@
+Termix is free and open source. If you find it useful, consider [donating](https://donate.termix.site/) to help cover server costs and development time. + +Monthly donation goal + +
+ Termix Banner
@@ -88,8 +94,8 @@ Manage files directly on remote servers with support for viewing and editing cod -**Docker Management:** -Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. +**Docker and Podman Management:** +Start, stop, pause, remove containers. View container stats. Control containers using a docker exec terminal. Supports both Docker and Podman as the container runtime. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. @@ -103,13 +109,13 @@ Save, organize, and manage your SSH connections with tags and folders (folder cu **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. +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. Includes time-series history graphs and threshold-based alerts with ntfy and webhook support. **User Authentication:** -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. +Secure user management with admin controls and OIDC/LDAP/SSO (with access control), 2FA (TOTP), and passkey (WebAuthn) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions. @@ -130,32 +136,52 @@ Create roles and share hosts across users/roles. +**Serial Connections:** +Connect to serial devices (routers, switches, microcontrollers, etc.) directly from the browser or desktop app. Configure baud rate, data bits, stop bits, and parity. Uses the Web Serial API in supported browsers or a native backend in the Electron app. + + + + +**Alerts:** +Set threshold-based alert rules on host metrics (CPU, memory, disk, etc.) and get notified via ntfy or webhooks when they fire. View firing and resolved alerts in a history log. + + + + + + +**Homepage:** +A fully customizable homepage with a drag-and-drop widget grid. Add widgets for host status, service links, clocks, notes, RSS feeds, weather, Docker containers, host metrics charts, embedded terminals, iframes, and more. + + + + **Database Encryption:** Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. + + **Network Graph:** Customize your Dashboard to visualize your homelab based off your SSH connections with status support. - - **SSH Tools:** Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. + + **Persistent Tabs:** SSH sessions and tabs stay open across devices/refreshes if enabled in user profile. - - **Languages:** @@ -180,7 +206,8 @@ Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/tr - **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 - **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. +- **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, SSH agent forwarding, Bitwarden SSH agent, HashiCorp Vault SSH signing, and more. +- **Termix ID** - A sshid.io equivalent built into Termix. Claim a handle, publish your public SSH keys at a resolver URL, and use a built-in CA to issue SSH certificates. @@ -314,6 +341,10 @@ Termix is free and open source with no subscriptions or paid plans. If you find Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Some videos and images may be out of date or may not perfectly showcase features. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 160a559e..2718dc5c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,123 +1,81 @@ -Dozens of bug fixes and small new features, including VNC/RDP sharing, OIDC improvements, file manager fixes, SSH jump host fixes, terminal enhancements, RDP keyboard layout support, and much more. +Major new features including serial connections, Tailscale/WireGuard support, HashiCorp Vault SSH auth, Bitwarden SSH agent, WebAuthn passkeys, Podman support, a new grid-based dashboard, host metrics history with alerting, and much more. -https://youtu.be/ImwAbm4hW-k +https://youtu.be/c3UD4q2jW_8 -- VNC, RDP, and Telnet credential sharing support -- Default host settings (SOCKS5, credentials, terminal settings, and more) -- Custom terminal background image per host -- Silent OIDC login configurable as default (no URL parameter required) -- Bulk open SSH sessions for multiple selected hosts at once -- Improved Nord theme contrast and accessibility -- Admin can manually create users while registration is disabled -- OIDC auto-provisioned users supported when registration and password login are disabled -- OIDC username usable as SSH username credential -- Custom labels and drag-to-reorder for sessions in the Connections panel -- Appearance and profile settings persisted to the database across devices -- Saved server URL dropdown in the app connection screen -- File manager text editor font size adjustment -- Tab key shortcut for entering your username in the terminal -- Shift+Tab hotkey support for mobile -- Terminal keyboard shortcuts support -- UTF-8 encoding support in the file manager -- Optional broadcast address for Wake-on-LAN packets -- SFTP legacy mode support -- Snippets JSON import and export with folder metadata -- Clickable links and service shortcuts on the dashboard -- Host status color indicators restored to sidebar -- Per-host configuration to set tab title to shell's window title instead of host name -- Split screen hotkeys -- Support for AZERTY and other non-QWERTY keyboard layouts in RDP -- RDP load balancing info and Connection Broker Cookie support -- Per-connection guacd proxy host and port configuration -- Deep link support for bookmarking direct SSH or file manager sessions -- ACME/Certbot SSL certificate support -- Import hosts from SSH config file -- OIDC with custom CA certificate support -- Open files directly in the file manager text editor -- File manager text editor Ctrl+W capture to prevent accidental tab close -- Option to collapse hosts to a single line in the sidebar -- Select a different backend Termix server from within the app -- Windows portable app now truly portable (no registry writes) -- Bundle fonts for offline environments -- Option to disable clickable links in the terminal -- Proxmox login options including OPKSSH -- UI suggestions and general interface improvements -- Per-protocol host metrics (online/offline detection) configuration -- Increase file manager max upload size by splicing files and reassembling them (~5GB) +- Termix ID with a public handle, hosted public key resolver, and built-in CA for issuing SSH certificates +- Serial connections support +- Tailscale and WireGuard VPN host integration with status detection +- HashiCorp Vault SSH signer authentication +- Bitwarden SSH agent integration +- WebAuthn passkey authentication +- Podman container runtime support alongside Docker +- SSH agent forwarding support across all SSH features +- New grid and widget-based dashboard homepage +- Grafana-style server stats history graphs +- Alert system with ntfy and webhook notification support +- Host temperature metrics card +- App fullscreen mode +- External editor support for file manager (desktop app) +- Safe host sharing export +- SSH credential password fallback for key-based auth +- Open all sessions in a folder at once +- Custom terminal theme color support +- Custom tunnel endpoints configuration +- GUACD_URL environment variable support +- App rail hover expansion setting +- Terminal font zoom with mouse wheel +- File manager terminals promoted to full tabs +- Donate button on dashboard +- PuTTY PPK SSH key support +- Confirmation dialog when closing active host connections +- Confirmation prompt before opening large files in the editor +- Cross-host file manager clipboard +- Prioritize host results in command palette search +- Retry autostart tunnel host fetches on failure -- File transfers over 100MB failing -- File transfers over 30 seconds aborted by axios timeout -- SSH terminal through jump host chain timing out with malformed SSH messages -- Cloning a host with SSH key auth not allowing auth method change on the clone -- File deletion in the file manager affecting selected files in inactive tabs -- File manager not connecting when cert passphrase is not saved -- File text editor cursor position lost on save -- macOS Option + Left/Right Arrow outputting raw ANSI sequences instead of moving cursor by word -- File manager tree view not sorted alphabetically -- SFTP failing with timeout when using a jump host chain -- SSH key auth with Duo not showing prompt and failing immediately -- Enabling 2FA with password login disabled causing a login deadlock -- Failed to disable 2FA even when providing correct TOTP or password -- Arrow buttons not working in Midnight Commander on the Android app -- Credential deploy command copy failing silently (clipboard API unavailable in some browsers) -- Disabling password login still showing the password login form -- Folder picker in the new host form not showing existing folders -- Command palette always opening hosts as SSH terminal regardless of protocol settings -- Saving SSH credentials failing on fresh installs -- Import hosts failing when hosts use SSH key credentials -- Warpgate authentication prompting for a password unnecessarily -- File manager folder icon not appearing under host name on hover -- File manager delete silently failing on Windows hosts (rm -f not supported by PowerShell) -- SSH terminal failing with keepalive timeout when server MOTD is slow to load -- SSH connection via SOCKS5 proxy failing after update -- Linking an OIDC account to a password account not working -- User password copy missing and RBAC issues in admin panel -- Debian and Android packages not opening the server on launch -- Username field in host edit and new host form having no effect -- Mobile terminal crashing on iOS with React error 130 -- Network interface symbols incorrect in host metrics -- Sudo password auto-fill not working on Ubuntu Server 26.04 -- get_cwd command injecting into live PTY and corrupting interactive programs -- Initial directory command failing on Windows PowerShell SSH targets -- 3-way split not working with layout issues -- Docker management not working for Windows hosts -- Docker management failing on Debian with exit code 1 -- Docker logs not respecting the current theme -- API not responding correctly after update -- Caddy reverse proxy configuration not working -- Wrong keyboard layout in VNC sessions -- KDE scaling issues in the desktop app -- Linux app failing to start due to better-sqlite3 Node version mismatch -- Tab autocomplete not working in the macOS x86 app -- Cloudflare SSL tunnels with third-level subdomains blocking Termix web portal access -- Fzf completion not working in the Android app -- SSH terminal frame delivery jitter in Docker (ssh2 native crypto not compiled) -- OIDC login failing in Linux and Android apps when Authentik has a Captcha stage -- Android app crashing after entering password when auth is set to None -- Editing or saving a host clearing the password for RDP and VNC connections -- Hardcoded 30-minute open-tabs TTL defeating session persistence -- Keyboard mapping issues on Windows Server 2019 via RDP -- Shared server not appearing for other users -- RBAC role assignment failing for OIDC users -- SSO configuration broken when supplied via environment variables -- RDP requiring credentials even when none are needed -- Terminal outputting success right after folder path -- GitHub/Google SSO provider giving ERR_INVALID_URL -- Keyboard focus not on main screen when selecting tmux session -- Remove all references to SALT variable -- Syntax highlighter duplicating path, visual corruptions, cursor jumps, etc. -- RDP session screen clips/spills over on viewport resize +- SSH port connection bug +- VNC required argument handshake failure +- Jump host SOCKS5 proxy selection using wrong proxy +- Tunnel endpoint resolution failing in some configurations +- Direct tunnel skipping endpoint credential validation incorrectly +- Dashboard host routing ignoring protocol settings +- Dashboard service link creation broken +- File manager uploads failing with 400 error and missing schema migrations on upgrade +- Large file manager uploads not chunked (chunked for files >=1.5GB) +- File uploads over 100MB failing due to ArrayBuffer browser limit +- File path case not preserved in file manager UI +- File downloads unreliable in the desktop app +- Tmux detection path handling incorrect +- Host metrics startup polling incorrect +- TUI terminal output highlighting incorrect +- Runtime base path for auth callbacks incorrect +- Windows app icon unstable +- SSH heading syntax highlighting broken +- Terminal link dialog layering issue +- Electron OIDC browser authentication failures +- Proxmox import auth fallback not working +- OIDC role credential shares not synced for OIDC users +- RDP connections requiring credentials when none are needed +- VNC authentication settings not persisted +- Guacamole unicode token corruption +- Guacamole websocket base path incorrect +- Guacamole disconnect during startup crash +- Host metrics starting for non-SSH hosts +- Sidebar host hover causing layout shift +- Alert UI incorrectly applying Termix CSS and alert system failing to load +- Translation key incorrect for nav close action +- PUID HTML ownership in Docker entrypoint diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..68bf4687 --- /dev/null +++ b/biome.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "dev-2.5.0" + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!!build", + "!!coverage", + "!!dist", + "!!dist-ssr", + "!!release", + "!!node_modules", + "!!src/mcp-server/node_modules", + "!!db", + "!!.env", + "!!**/*.min.js", + "!!**/*.min.css", + "!!openapi.json" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80, + "lineEnding": "lf" + }, + "linter": { + "enabled": false + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always" + } + }, + "json": { + "formatter": { + "trailingCommas": "none" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": false + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 5b2c28e1..a016afbd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Install dependencies -FROM node:26-slim AS deps +FROM node:24-slim AS deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* @@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3 RUN npm run build:backend # Stage 4: Production dependencies only -FROM node:26-slim AS production-deps +FROM node:24-slim AS production-deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* @@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \ npm cache clean --force # Stage 5: Final optimized image -FROM node:26-slim +FROM node:24-slim WORKDIR /app ENV DATA_DIR=/app/data \ @@ -78,7 +78,7 @@ COPY --chown=node:node package.json ./ VOLUME ["/app/data"] -EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 +EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012 HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget -q -O /dev/null http://localhost:30001/health || exit 1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c46e28c2..917ea608 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -14,7 +14,7 @@ if [ "$(id -u)" = "0" ]; then groupmod -o -g "$PGID" node 2>/dev/null || true usermod -o -u "$PUID" node 2>/dev/null || true - chown -R node:node /app/data /app/uploads /tmp/nginx 2>/dev/null || true + chown -R node:node /app/data /app/uploads /app/html /tmp/nginx 2>/dev/null || true echo "User node is now UID: $PUID, GID: $PGID" @@ -167,4 +167,4 @@ node dist/backend/backend/starter.js echo "All services started" -tail -f /dev/null \ No newline at end of file +tail -f /dev/null diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index ee58e8fb..d2e38f5b 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -159,6 +159,33 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/alert-rules(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/notification-channels(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/alert-firings(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + location ~ ^/rbac(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -190,6 +217,24 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/vault(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/termix-id(/.*)?$ { + 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 ~ ^/c2s-tunnel-presets(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -610,6 +655,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/homepage(/.*)?$ { + proxy_pass http://127.0.0.1:30012; + 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 $proxy_x_forwarded_proto; + } + location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; @@ -663,6 +717,27 @@ http { proxy_read_timeout 300s; } + location ^~ /serial/websocket/ { + proxy_pass http://127.0.0.1:30011/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_cache_bypass $http_upgrade; + + 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 $proxy_x_forwarded_proto; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 10s; + + proxy_buffering off; + proxy_request_buffering off; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; diff --git a/docker/nginx.conf b/docker/nginx.conf index 5da83bcf..a8a44e2a 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -148,6 +148,33 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/alert-rules(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/notification-channels(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/alert-firings(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + location ~ ^/rbac(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -179,6 +206,24 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/vault(/.*)?$ { + 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 $proxy_x_forwarded_proto; + } + + location ~ ^/termix-id(/.*)?$ { + 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 ~ ^/proxmox(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; @@ -611,6 +656,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/homepage(/.*)?$ { + proxy_pass http://127.0.0.1:30012; + 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 $proxy_x_forwarded_proto; + } + location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; @@ -664,6 +718,27 @@ http { proxy_read_timeout 300s; } + location ^~ /serial/websocket/ { + proxy_pass http://127.0.0.1:30011/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_cache_bypass $http_upgrade; + + 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 $proxy_x_forwarded_proto; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 10s; + + proxy_buffering off; + proxy_request_buffering off; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; diff --git a/electron-builder.json b/electron-builder.json index d2eb0685..81106b0b 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -28,11 +28,8 @@ "!dist/icon-mac.png", "!public/icon-mac.png", "!dist/icon.ico", - "!public/icon.ico", "!dist/icon.icns", - "!public/icon.icns", - "!dist/icons/**/*", - "!public/icons/**/*" + "!public/icon.icns" ], "extraMetadata": { "main": "electron/main.cjs", @@ -61,6 +58,8 @@ "artifactName": "termix_windows_${arch}_nsis.${ext}", "createDesktopShortcut": true, "createStartMenuShortcut": true, + "installerIcon": "public/icon.ico", + "uninstallerIcon": "public/icon.ico", "shortcutName": "Termix", "uninstallDisplayName": "Termix" }, diff --git a/electron/main.cjs b/electron/main.cjs index 75600256..2efe350f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -9,6 +9,7 @@ const { safeStorage, Tray, clipboard, + nativeImage, } = require("electron"); const path = require("path"); const fs = require("fs"); @@ -17,7 +18,7 @@ const https = require("https"); const http = require("http"); const net = require("net"); const { URL } = require("url"); -const { fork } = require("child_process"); +const { fork, spawn } = require("child_process"); const WebSocket = require("ws"); // Portable mode: if a `.portable` marker exists next to the executable, @@ -528,15 +529,117 @@ let backendProcess = null; let backendStartFailed = false; let tray = null; let isQuitting = false; +const tempFiles = new Map(); +const externalEditorSessions = new Map(); const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); +const windowsAppUserModelId = "com.karmaa.termix"; const electronCacheBuildPath = path.join( app.getPath("userData"), "client-cache-build.json", ); const termixSessionPartition = "persist:termix"; +function getTempRoot() { + const tempRoot = path.join(app.getPath("temp"), "termix"); + fs.mkdirSync(tempRoot, { recursive: true }); + return tempRoot; +} + +function sanitizeFileName(fileName) { + const baseName = path.basename(String(fileName || "file")); + return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file"; +} + +function decodeFileContent(content, encoding) { + if (encoding === "base64") { + return Buffer.from(String(content || ""), "base64"); + } + return Buffer.from(String(content || ""), "utf8"); +} + +function createManagedTempFile(fileName, content, encoding = "utf8") { + const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`)); + const filePath = path.join(tempDir, sanitizeFileName(fileName)); + fs.writeFileSync(filePath, decodeFileContent(content, encoding)); + tempFiles.set(tempId, { path: filePath, dir: tempDir }); + return { tempId, path: filePath }; +} + +function cleanupManagedTempFile(tempId) { + const temp = tempFiles.get(tempId); + if (!temp) return; + try { + fs.rmSync(temp.dir, { recursive: true, force: true }); + } catch (error) { + logToFile("Failed to clean up temporary file:", error.message); + } + tempFiles.delete(tempId); +} + +function closeExternalEditorSession(editId) { + const session = externalEditorSessions.get(editId); + if (!session) return; + if (session.timer) clearTimeout(session.timer); + try { + session.watcher.close(); + } catch { + // watcher may already be closed + } + externalEditorSessions.delete(editId); + cleanupManagedTempFile(editId); +} + +function notifyExternalEditorSaved(editId) { + const session = externalEditorSessions.get(editId); + if (!session || !mainWindow || mainWindow.isDestroyed()) return; + + try { + const stat = fs.statSync(session.path); + if (stat.mtimeMs === session.lastMtimeMs) return; + session.lastMtimeMs = stat.mtimeMs; + + const content = fs.readFileSync(session.path, "utf8"); + mainWindow.webContents.send("external-editor-saved", { + editId, + content, + encoding: "utf8", + path: session.path, + }); + } catch (error) { + logToFile("Failed to read external editor file:", error.message); + } +} + +function openPathWithEditor(filePath, editorPath) { + if (!editorPath) { + return shell.openPath(filePath); + } + + return new Promise((resolve) => { + let settled = false; + const child = spawn(editorPath, [filePath], { + detached: true, + stdio: "ignore", + }); + + child.once("error", (error) => { + if (settled) return; + settled = true; + resolve(error.message); + }); + + setTimeout(() => { + if (settled) return; + settled = true; + child.unref(); + resolve(""); + }, 500); + }); +} + app.on( "certificate-error", (event, _webContents, url, error, certificate, callback) => { @@ -690,9 +793,11 @@ function getBackendPaths() { backendCwd: backendDir, }; } - // fork() does not go through Electron's asar redirector — use the unpacked path + // fork() does not go through Electron's asar redirector — use the unpacked path. + // On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR + // app-arm64.asar / app-x64.asar instead of app.asar, so match all variants. const unpackedRoot = appRoot.replace( - /app\.asar(?!\.unpacked)/, + /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/, "app.asar.unpacked", ); const backendDir = path.join(unpackedRoot, "dist", "backend", "backend"); @@ -850,7 +955,13 @@ function createTray() { // use the unpacked path so the OS sees a real file. const publicRoot = isDev ? path.join(appRoot, "public") - : path.join(appRoot.replace("app.asar", "app.asar.unpacked"), "public"); + : path.join( + appRoot.replace( + /app(-[a-z0-9]+)?\.asar(?!\.unpacked)/, + "app.asar.unpacked", + ), + "public", + ); let trayIcon; if (process.platform === "darwin") { @@ -920,7 +1031,11 @@ function createWindow() { minWidth: 800, minHeight: 600, title: "Termix", - icon: path.join(appRoot, "public", "icon.png"), + icon: path.join( + appRoot, + "public", + process.platform === "win32" ? "icon.ico" : "icon.png", + ), webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -1231,9 +1346,11 @@ ipcMain.handle( async (_event, authUrl, callbackPort) => { const http = require("http"); - return new Promise((resolve, reject) => { + return new Promise((resolve) => { + let timeout; + let settled = false; const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://localhost:${callbackPort}`); + const url = new URL(req.url || "/", `http://localhost:${callbackPort}`); if (url.pathname === "/oidc-callback") { const success = url.searchParams.get("success"); const error = url.searchParams.get("error"); @@ -1244,27 +1361,54 @@ ipcMain.handle( `

${success === "true" ? "Authentication successful!" : "Authentication failed."}

You can close this tab and return to Termix.

`, ); - server.close(); if (success === "true") { - resolve({ success: true, token }); + finish({ success: true, token }); } else { - resolve({ + finish({ success: false, error: error || "Authentication failed", }); } + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + }); + + const finish = (result) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + try { + server.close(); + } catch { + // Server may not have started yet. + } + resolve(result); + }; + + const fail = (error) => { + finish({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + }; + + server.once("error", fail); + + server.listen(callbackPort, "127.0.0.1", async () => { + try { + await shell.openExternal(authUrl); + } catch (error) { + fail(error); } }); - server.listen(callbackPort, "127.0.0.1", () => { - shell.openExternal(authUrl); - }); - // Timeout after 5 minutes - setTimeout( + timeout = setTimeout( () => { - server.close(); - reject(new Error("OIDC authentication timed out")); + fail(new Error("OIDC authentication timed out")); }, 5 * 60 * 1000, ); @@ -2500,6 +2644,131 @@ ipcMain.handle("clipboard-write-text", (_event, text) => { ipcMain.handle("clipboard-read-text", () => clipboard.readText()); +ipcMain.handle("show-save-dialog", async (_event, options) => { + return dialog.showSaveDialog(mainWindow, options || {}); +}); + +ipcMain.handle("show-open-dialog", async (_event, options) => { + return dialog.showOpenDialog(mainWindow, options || {}); +}); + +ipcMain.handle("create-temp-file", async (_event, fileData) => { + try { + const result = createManagedTempFile( + fileData?.fileName, + fileData?.content, + fileData?.encoding, + ); + return { success: true, ...result }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("create-temp-folder", async (_event, folderData) => { + try { + const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`)); + const folderPath = path.join( + tempDir, + sanitizeFileName(folderData?.folderName || "files"), + ); + fs.mkdirSync(folderPath, { recursive: true }); + + for (const file of folderData?.files || []) { + const relativePath = String(file.relativePath || "") + .split(/[\\/]+/) + .map(sanitizeFileName) + .filter(Boolean) + .join(path.sep); + if (!relativePath) continue; + const targetPath = path.join(folderPath, relativePath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync( + targetPath, + decodeFileContent(file.content, file.encoding), + ); + } + + tempFiles.set(tempId, { path: folderPath, dir: tempDir }); + return { success: true, tempId, path: folderPath }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("start-drag-to-desktop", (event, dragData) => { + try { + const temp = tempFiles.get(dragData?.tempId); + if (!temp) return { success: false, error: "Temporary file not found" }; + + event.sender.startDrag({ + file: temp.path, + icon: nativeImage.createEmpty(), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("cleanup-temp-file", (_event, tempId) => { + try { + cleanupManagedTempFile(tempId); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("open-external-editor", async (_event, fileData) => { + try { + const result = createManagedTempFile( + fileData?.fileName, + fileData?.content, + fileData?.encoding, + ); + const editId = result.tempId; + const stat = fs.statSync(result.path); + const watcher = fs.watch(result.path, { persistent: false }, () => { + const session = externalEditorSessions.get(editId); + if (!session) return; + if (session.timer) clearTimeout(session.timer); + session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500); + }); + + externalEditorSessions.set(editId, { + path: result.path, + watcher, + timer: null, + lastMtimeMs: stat.mtimeMs, + }); + + const editorPath = + typeof fileData?.editorPath === "string" && fileData.editorPath.trim() + ? fileData.editorPath.trim() + : null; + const openError = await openPathWithEditor(result.path, editorPath); + if (openError) { + closeExternalEditorSession(editId); + return { success: false, error: openError }; + } + + return { success: true, editId, path: result.path }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + +ipcMain.handle("close-external-editor", (_event, editId) => { + try { + closeExternalEditorSession(editId); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const normalizedServerUrl = serverUrl.replace(/\/$/, ""); @@ -2680,6 +2949,9 @@ app.whenReady().then(async () => { "arch:", process.arch, ); + if (process.platform === "win32") { + app.setAppUserModelId(windowsAppUserModelId); + } createMenu(); await clearElectronClientCacheIfBuildChanged(); await clearElectronJwtCookiesAtStartup(); @@ -2719,6 +2991,12 @@ app.on("before-quit", () => { app.on("will-quit", () => { console.log("App will quit..."); + for (const editId of externalEditorSessions.keys()) { + closeExternalEditorSession(editId); + } + for (const tempId of tempFiles.keys()) { + cleanupManagedTempFile(tempId); + } stopAllC2STunnels(); stopBackendServer(); }); diff --git a/electron/preload.js b/electron/preload.js index ea3becad..6b9cf1c0 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -46,6 +46,26 @@ contextBridge.exposeInMainWorld("electronAPI", { oidcSystemBrowserAuth: (authUrl, callbackPort) => ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort), + openExternalEditor: (fileData) => + ipcRenderer.invoke("open-external-editor", fileData), + closeExternalEditor: (editId) => + ipcRenderer.invoke("close-external-editor", editId), + onExternalEditorSaved: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on("external-editor-saved", listener); + return () => ipcRenderer.removeListener("external-editor-saved", listener); + }, + + showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options), + showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options), + createTempFile: (fileData) => + ipcRenderer.invoke("create-temp-file", fileData), + createTempFolder: (folderData) => + ipcRenderer.invoke("create-temp-folder", folderData), + startDragToDesktop: (dragData) => + ipcRenderer.invoke("start-drag-to-desktop", dragData), + cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/package-lock.json b/package-lock.json index 4010ad1b..06505929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "termix", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "2.4.1", + "version": "2.5.0", "hasInstallScript": true, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@types/ldapjs": "^3.0.6", "axios": "^1.18.0", "bcryptjs": "^3.0.3", @@ -22,14 +24,15 @@ "express": "^5.2.1", "guacamole-lite": "^1.2.0", "jose": "^6.2.2", - "js-yaml": "^4.2.0", + "js-yaml": "^5.0.0", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.1", "ldapjs": "^3.0.7", "motion": "^12.38.0", "multer": "^2.2.0", - "nanoid": "^5.1.9", + "nanoid": "^5.1.15", "qrcode": "^1.5.4", + "serialport": "^13.0.0", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", @@ -37,17 +40,18 @@ "ws": "^8.20.0" }, "devDependencies": { + "@biomejs/biome": "2.5.1", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.41.1", + "@codemirror/view": "^6.43.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", + "@eslint/js": "^10.0.1", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource/fira-code": "^5.2.7", "@fontsource/jetbrains-mono": "^5.2.8", @@ -69,7 +73,7 @@ "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", - "@tailwindcss/vite": "^4.2.4", + "@tailwindcss/vite": "^4.3.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -82,7 +86,7 @@ "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -93,8 +97,8 @@ "@uiw/codemirror-theme-github": "^4.25.9", "@uiw/react-codemirror": "^4.25.9", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/ui": "^4.1.9", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", @@ -103,13 +107,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "cytoscape": "^3.34.0", "electron": "^42.4.1", "electron-builder": "^26.15.3", - "eslint": "^9.0.0", + "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.5.0", "guacamole-common-js": "^1.5.0", @@ -117,9 +121,9 @@ "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", - "lint-staged": "^17.0.7", + "lint-staged": "^17.0.8", "lucide-react": "^1.20.0", - "prettier": "3.8.3", + "prettier": "3.8.4", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-cytoscapejs": "^2.0.0", @@ -134,7 +138,7 @@ "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", "remark-gfm": "^4.0.1", - "sharp": "^0.35.1", + "sharp": "^0.35.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", @@ -143,7 +147,7 @@ "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "engines": { "node": ">=22.12.0", @@ -174,6 +178,29 @@ "url": "https://github.com/sponsors/philsturgeon" } }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@apidevtools/openapi-schemas": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", @@ -540,6 +567,181 @@ "node": ">=18" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.1.tgz", + "integrity": "sha512-IXWLCxKmae+rI7LOHS1B3EbVisQ6GRAWbhN9msa6KjNCyFWrvKZWR4oUdinaNssrV852OrSHuSPa95h1GPJc7Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.1", + "@biomejs/cli-darwin-x64": "2.5.1", + "@biomejs/cli-linux-arm64": "2.5.1", + "@biomejs/cli-linux-arm64-musl": "2.5.1", + "@biomejs/cli-linux-x64": "2.5.1", + "@biomejs/cli-linux-x64-musl": "2.5.1", + "@biomejs/cli-win32-arm64": "2.5.1", + "@biomejs/cli-win32-x64": "2.5.1" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-npqDzvqv7vFaWRiNN1Te71siRgPaqS9MpqgYCdP/CrUbkJ7ApezaeaKjueKHRN/JH/6lRjJQAHi8acQDCAz22w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.1.tgz", + "integrity": "sha512-RgwTqPAM8g2tn1j+b5oRjF/DbSBX8a4gwojtuG9XuhfK7GgomvZ9+T+tqjXiVbjLEeGJOoL6VEk8mvRTVeSybw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.1.tgz", + "integrity": "sha512-yhV35CzZh38VyMvTEXi3JTjxZBs++oCKK9KG8vB6VI5+uvQvZNR3BFWEKKzuOmx9DJJj7sQpZ4LQJcmbGTs3+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-WMcvMLgByyTqVxGlq918NBBYliq9FRR9GAQVETHb+VjGVqXCZFfHlZHC1FX4ibuYY/Hg6TJE3rHU0xVrdJXNRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.1.tgz", + "integrity": "sha512-J/7uHSX7NfoYDI7HijAkd8lnQIOrRb2W7j3X+tw4R+N5ExvXGsyXFiGdQcfcxfOmNQmZVSQOCDk757fwpzqQcg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-ANTowtlLmPYm5yeMckWY8Xzb9Ix+JJP3tgHR/n6xRj1VWyIzzWtfRfih9hv9VmClwadpBvZduISZIbBsIlYG3A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.1.tgz", + "integrity": "sha512-zgXnKNgWPC4iPF7Y1lR3STUeCUuZRpD6IiOrC7TZTlh0Lx6FiVUT05myuMQHQ9D+1cc7uyMldi4forE6lp0ivQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.1.tgz", + "integrity": "sha512-6uxpR9hvaglANkZemeSiN/FhYgkGasrEGn267eXIWvjrjJ2LhDlk251IhjVJq6MXzkV2/bcXwLwSroLyPtqRZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -903,9 +1105,9 @@ } }, "node_modules/@codemirror/search": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", - "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", "dev": true, "license": "MIT", "dependencies": { @@ -938,9 +1140,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.43.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", - "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", "dev": true, "license": "MIT", "dependencies": { @@ -1848,204 +2050,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { @@ -2148,6 +2235,12 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2248,9 +2341,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.1.tgz", - "integrity": "sha512-T15JRWOubQ3f5+GxnWeIvo47u5qV0M9HBgJhT+f2gE1e9e6OhR6K73Re52Hm80qWcu1DNb3GweKmpr/MnuP2Ow==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", "cpu": [ "arm64" ], @@ -2267,13 +2360,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.0" + "@img/sharp-libvips-darwin-arm64": "1.3.1" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.1.tgz", - "integrity": "sha512-t1CPD0cr7XCHjwUj6tQ5MC0pCi866I+gUW6zbUX4aFPnKd1DFBtk0M+gWcjX8VeEzgfCNiSiNTVFZ6b7kvdbnQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", "cpu": [ "x64" ], @@ -2290,13 +2383,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.0" + "@img/sharp-libvips-darwin-x64": "1.3.1" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.1.tgz", - "integrity": "sha512-MBSQXqNPThW9EcZ905H6N4sEdX5EwZEYzGx5EBq9ncDCGJALMiY1xPFJxNdzuB1iBjLOpIfxajM6YxdvwmQSLA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", "dev": true, "license": "Apache-2.0", "optional": true, @@ -2304,7 +2397,7 @@ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.1" + "@img/sharp-wasm32": "0.35.2" }, "engines": { "node": ">=20.9.0" @@ -2314,9 +2407,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", "cpu": [ "arm64" ], @@ -2331,9 +2424,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", - "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", "cpu": [ "x64" ], @@ -2348,9 +2441,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", - "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", "cpu": [ "arm" ], @@ -2368,9 +2461,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", - "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", "cpu": [ "arm64" ], @@ -2388,9 +2481,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", - "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", "cpu": [ "ppc64" ], @@ -2408,9 +2501,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", - "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", "cpu": [ "riscv64" ], @@ -2428,9 +2521,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", - "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", "cpu": [ "s390x" ], @@ -2448,9 +2541,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", - "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", "cpu": [ "x64" ], @@ -2468,9 +2561,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", - "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", "cpu": [ "arm64" ], @@ -2488,9 +2581,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", - "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", "cpu": [ "x64" ], @@ -2508,9 +2601,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.1.tgz", - "integrity": "sha512-jygmR02PpCYypt7xB7nst1vqjZp/BpRA/Kf9nK7qRponJ/KrLPaZWEG4G15z1d2FZ6XqI+T0350ha3RSnKx24A==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", "cpu": [ "arm" ], @@ -2530,13 +2623,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.0" + "@img/sharp-libvips-linux-arm": "1.3.1" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.1.tgz", - "integrity": "sha512-ErCRyGU7LeoaFBZ0xW8hhLlXzhAg80sc4vxePB86qvtEvW1jEhhmbiNBP4oEzZfPMnu6HwHXfzD2W2kBU+RnCw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", "cpu": [ "arm64" ], @@ -2556,13 +2649,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.0" + "@img/sharp-libvips-linux-arm64": "1.3.1" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.1.tgz", - "integrity": "sha512-LUWZ2+r2UoLCd8j0RLCwQ4gL6w47+Y7igxtVnPIDXOOEjV86LpBkAHq5VpJeg+GHbw0KN/JWlPJOdZjyZnFqFQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", "cpu": [ "ppc64" ], @@ -2582,13 +2675,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.0" + "@img/sharp-libvips-linux-ppc64": "1.3.1" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.1.tgz", - "integrity": "sha512-i7x6J3mwF4JgT0sM4V4WlAWdJ0bucPtA9rzO1bTji1n5qgBq/W5nn87RvOQPleuuxahNoLdTngByD8/vDDLArw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", "cpu": [ "riscv64" ], @@ -2608,13 +2701,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.0" + "@img/sharp-libvips-linux-riscv64": "1.3.1" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.1.tgz", - "integrity": "sha512-0zSaTUjTF0kIWTSYxD4EG/nvCU4jez53+3RdURtoY3HvbXtIQ98W90JnrGz/oLRFuEnfIy9+7xeq883euc0ZWw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", "cpu": [ "s390x" ], @@ -2634,13 +2727,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.0" + "@img/sharp-libvips-linux-s390x": "1.3.1" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.1.tgz", - "integrity": "sha512-NbJD4mWdeyrNQKluO/tR/wBDOelcowSVGNBWxI0e3ZtlXc6F/UOVKDj1MLD4zl3oHTuvKW3s+MA9N54YTldAYw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", "cpu": [ "x64" ], @@ -2660,13 +2753,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.0" + "@img/sharp-libvips-linux-x64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.1.tgz", - "integrity": "sha512-VoW2sQCWI+0YIKQEmWJ8vzaQjTg9wIyfkFpvEfAS2h43X6iHu7GTk1hhOgB4IpSzCHe8UwQZIcx7b81VTaOrJA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", "cpu": [ "arm64" ], @@ -2686,13 +2779,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.1.tgz", - "integrity": "sha512-LjBoSd/c5JU0/K5MwzDMlgsSRP2bPn98JQGFFQAOLQ0bU/1z4ekxUdSKY9BmlwSh/cA+OrvpgsWqfZyYfVHBRw==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", "cpu": [ "x64" ], @@ -2712,18 +2805,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.1.tgz", - "integrity": "sha512-PCQUoQdZyE8tp3HpbevuihfUmgSP4qWI0FGEPWoeXqaS+cUrFfemabHQiebUmUmlUhCuNnQMxGrQ+CPqK4hnxg==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { "node": ">=20.9.0" @@ -2744,9 +2837,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.1.tgz", - "integrity": "sha512-xU2ml2bU2OPxYVvW2A6ae4M1g5QKyhKG06P4FAt+YEaFQQO0919Qx+XxIZEUuWTMoDViLpMws2/dQwoe/VcA6A==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", "cpu": [ "wasm32" ], @@ -2754,7 +2847,7 @@ "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.1" + "@img/sharp-wasm32": "0.35.2" }, "engines": { "node": ">=20.9.0" @@ -2764,9 +2857,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.1.tgz", - "integrity": "sha512-IkmHwuFhYpd3bTsN5SAahjwhiAcyXPooBt8vEUgxY3T0IP70sSJ0nU1xiPzZY8AH/OB1XpV3j8aZSVSOSfTbdA==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", "cpu": [ "arm64" ], @@ -2784,9 +2877,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.1.tgz", - "integrity": "sha512-wQahqCi9MD8Yxzg4gVM4fNrZxh+r6vD55PyIg+WJPaM5ZRUyF35iQpwJCuma3r6viU9/8Pxlc+XHV+woVa6nCQ==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", "cpu": [ "ia32" ], @@ -2804,9 +2897,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.1.tgz", - "integrity": "sha512-WzBtkYtZHATLPe8XRharxZXxQ9cdLrQWHiwxt+BJ5rBsisQrKeeV86ErxPSVhcG6xCEuNhs0SqLpWr7XDa2k6w==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", "cpu": [ "x64" ], @@ -2991,6 +3084,12 @@ "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", "license": "MIT" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", @@ -3576,11 +3675,112 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/@peculiar/asn1-schema": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", @@ -3588,6 +3788,30 @@ "tslib": "^2.8.1" } }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/@peculiar/json-schema": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", @@ -3605,7 +3829,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -3628,6 +3851,28 @@ "node": ">=14.18.0" } }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -5524,6 +5769,245 @@ } } }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -5553,6 +6037,31 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5771,6 +6280,29 @@ } } }, + "node_modules/@svgr/core/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -5813,49 +6345,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -5870,9 +6402,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -5887,9 +6419,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -5904,9 +6436,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -5921,9 +6453,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -5938,13 +6470,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5955,13 +6490,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5972,13 +6510,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5989,13 +6530,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6006,9 +6550,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -6028,17 +6572,83 @@ "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -6053,9 +6663,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -6070,15 +6680,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -6280,6 +6890,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6418,12 +7035,12 @@ } }, "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/prismjs": { @@ -6945,14 +7562,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -6966,8 +7583,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6976,16 +7593,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -6994,13 +7611,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -7031,9 +7648,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7044,13 +7661,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -7058,14 +7675,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7074,9 +7691,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -7084,13 +7701,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz", - "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz", + "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -7102,17 +7719,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.8" + "vitest": "4.1.9" } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7201,9 +7818,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -7436,6 +8053,29 @@ "node": ">=18" } }, + "node_modules/app-builder-lib/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/app-builder-lib/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -7520,7 +8160,6 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", @@ -8054,6 +8693,29 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/builder-util/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/builder-util/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8536,58 +9198,127 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/content-disposition": { @@ -8768,6 +9499,29 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/cosmiconfig/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/cpu-features": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", @@ -9053,6 +9807,29 @@ "js-yaml": "^4.1.0" } }, + "node_modules/dmg-builder/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/dom-accessibility-api": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", @@ -9477,9 +10254,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", - "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9636,33 +10413,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -9672,8 +10449,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -9681,7 +10457,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -9716,9 +10492,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -9742,17 +10518,19 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -9788,41 +10566,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9830,45 +10573,19 @@ "dev": true, "license": "MIT" }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -11355,9 +12072,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz", + "integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==", "funding": [ { "type": "github", @@ -11373,7 +12090,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsdom": { @@ -11891,9 +12608,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -12088,13 +12805,6 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -13569,9 +14279,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", - "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz", + "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==", "funding": [ { "type": "github", @@ -13632,6 +14342,15 @@ "node": ">=22.12.0" } }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -13667,6 +14386,17 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -14253,9 +14983,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -14457,7 +15187,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -14467,7 +15196,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -15122,6 +15850,12 @@ "node": ">=8" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/refractor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", @@ -15460,6 +16194,51 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/serialport/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -15498,9 +16277,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.1.tgz", - "integrity": "sha512-lW979AMi+ESidzMv/Lnv+F9bknzLyxLqFI05Sm433vOeRcltgxQmXpnfOOFIAlKtwXU/ksupm2srQoFCkR214g==", + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -15515,31 +16294,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.1", - "@img/sharp-darwin-x64": "0.35.1", - "@img/sharp-freebsd-wasm32": "0.35.1", - "@img/sharp-libvips-darwin-arm64": "1.3.0", - "@img/sharp-libvips-darwin-x64": "1.3.0", - "@img/sharp-libvips-linux-arm": "1.3.0", - "@img/sharp-libvips-linux-arm64": "1.3.0", - "@img/sharp-libvips-linux-ppc64": "1.3.0", - "@img/sharp-libvips-linux-riscv64": "1.3.0", - "@img/sharp-libvips-linux-s390x": "1.3.0", - "@img/sharp-libvips-linux-x64": "1.3.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", - "@img/sharp-libvips-linuxmusl-x64": "1.3.0", - "@img/sharp-linux-arm": "0.35.1", - "@img/sharp-linux-arm64": "0.35.1", - "@img/sharp-linux-ppc64": "0.35.1", - "@img/sharp-linux-riscv64": "0.35.1", - "@img/sharp-linux-s390x": "0.35.1", - "@img/sharp-linux-x64": "0.35.1", - "@img/sharp-linuxmusl-arm64": "0.35.1", - "@img/sharp-linuxmusl-x64": "0.35.1", - "@img/sharp-webcontainers-wasm32": "0.35.1", - "@img/sharp-win32-arm64": "0.35.1", - "@img/sharp-win32-ia32": "0.35.1", - "@img/sharp-win32-x64": "0.35.1" + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" } }, "node_modules/shebang-command": { @@ -15566,9 +16345,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -16023,19 +16802,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -16077,16 +16843,13 @@ } }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" @@ -16118,9 +16881,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, @@ -16437,6 +17200,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -16563,9 +17344,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unified": { @@ -17003,19 +17784,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -17043,12 +17824,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index f8efb547..afc647c0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "2.4.1", + "version": "2.5.0", "description": "Self-hosted SSH and remote desktop management.", "author": "Karmaa", "main": "electron/main.cjs", @@ -12,6 +12,8 @@ "scripts": { "format": "prettier --write .", "format:check": "prettier --check .", + "biome:check": "biome check biome.json package.json", + "biome:fix": "biome check --write biome.json package.json", "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs", "prebuild": "node scripts/write-electron-build-info.cjs", "lint": "eslint .", @@ -31,7 +33,7 @@ "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:patch-builder": "node scripts/patch-app-builder-lib.cjs", - "electron:rebuild": "electron-rebuild -f -w better-sqlite3", + "electron:rebuild": "electron-rebuild -f -w better-sqlite3 -w serialport", "build:win-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --dir", "build:win-installer": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --publish=never", "build:linux-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux --dir", @@ -41,6 +43,8 @@ "build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@types/ldapjs": "^3.0.6", "axios": "^1.18.0", "bcryptjs": "^3.0.3", @@ -54,14 +58,15 @@ "express": "^5.2.1", "guacamole-lite": "^1.2.0", "jose": "^6.2.2", - "js-yaml": "^4.2.0", + "js-yaml": "^5.0.0", "jsonwebtoken": "^9.0.3", "jszip": "^3.10.1", "ldapjs": "^3.0.7", "motion": "^12.38.0", "multer": "^2.2.0", - "nanoid": "^5.1.9", + "nanoid": "^5.1.15", "qrcode": "^1.5.4", + "serialport": "^13.0.0", "socks": "^2.8.7", "speakeasy": "^2.0.0", "ssh2": "^1.17.0", @@ -69,17 +74,18 @@ "ws": "^8.20.0" }, "devDependencies": { + "@biomejs/biome": "2.5.1", "@codemirror/autocomplete": "^6.20.3", "@codemirror/commands": "^6.10.3", - "@codemirror/search": "^6.7.0", + "@codemirror/search": "^6.7.1", "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.41.1", + "@codemirror/view": "^6.43.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", + "@eslint/js": "^10.0.1", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource/fira-code": "^5.2.7", "@fontsource/jetbrains-mono": "^5.2.8", @@ -101,7 +107,7 @@ "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", - "@tailwindcss/vite": "^4.2.4", + "@tailwindcss/vite": "^4.3.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -114,7 +120,7 @@ "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", - "@types/node": "^25.9.2", + "@types/node": "^26.0.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -125,8 +131,8 @@ "@uiw/codemirror-theme-github": "^4.25.9", "@uiw/react-codemirror": "^4.25.9", "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/ui": "^4.1.9", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", @@ -135,13 +141,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "concurrently": "^9.2.1", + "concurrently": "^10.0.3", "cytoscape": "^3.34.0", "electron": "^42.4.1", "electron-builder": "^26.15.3", - "eslint": "^9.0.0", + "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.5.0", "guacamole-common-js": "^1.5.0", @@ -149,9 +155,9 @@ "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", "jsdom": "^29.1.1", - "lint-staged": "^17.0.7", + "lint-staged": "^17.0.8", "lucide-react": "^1.20.0", - "prettier": "3.8.3", + "prettier": "3.8.4", "radix-ui": "^1.6.0", "react": "^19.2.7", "react-cytoscapejs": "^2.0.0", @@ -166,7 +172,7 @@ "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", "remark-gfm": "^4.0.1", - "sharp": "^0.35.1", + "sharp": "^0.35.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", @@ -175,7 +181,7 @@ "typescript-eslint": "^8.61.1", "vite": "^8.0.16", "vite-plugin-svgr": "^5.2.0", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/readme/HOST-TO-HOST-TRANSFER.md b/readme/HOST-TO-HOST-TRANSFER.md deleted file mode 100644 index d156e4e0..00000000 --- a/readme/HOST-TO-HOST-TRANSFER.md +++ /dev/null @@ -1,185 +0,0 @@ -# Host-to-host file transfer - -This document describes the host-to-host copy/move feature. It is intended for operators and contributors. Direct host-to-host routing via SSH tunnels is **not implemented**; that is documented under [Future work](#future-work-direct-routing-via-tunnels). - -## Overview - -Host-to-host transfer copies or moves files from one SSH host to another through the **Termix server** as a relay. The UI lives in **File Manager**: right-click files or folders and choose **Copy to host…** or **Move to host…**. - -Compared to copying via your laptop (`scp -3` or `ssh one 'cat …' | ssh two 'cat …'`), Termix keeps the job on the server so transfers continue if you close the browser, and you get integrated progress, cancel, retry, and cleanup. - -```mermaid -flowchart LR - Browser[Browser_UI] - Termix[Termix_server] - Src[Source_host] - Dst[Destination_host] - - Browser -->|start_transfer_API| Termix - Termix -->|dedicated_SSH_xfer_src| Src - Termix -->|dedicated_SSH_xfer_dst| Dst - Termix -->|SFTP_read_then_write| Termix -``` - -Data for remote-to-remote paths **always passes through the Termix process** (pipelined SFTP buffers, or a tar archive stream). There is no source→destination SSH tunnel for file bytes today. - -## Prerequisites - -1. **Two hosts** with File Manager enabled, saved in Host Manager. -2. **Both hosts reachable from the Termix server** on SSH (each host’s jump hosts and SOCKS5 proxy chain apply to the **Termix→host** connection, not host→host). -3. **File Manager open** on the source host (browse session connected). The destination host should show **Ready** in the transfer dialog; if it shows authentication required, open File Manager on that host once first. -4. For **multi-file or folder** transfers, pick a **destination directory** (not a file path). - -The dialog shows a reminder: _“Both hosts must be reachable from the Termix server. Direct host-to-host routing is not supported.”_ - -## Using the UI - -### Start a transfer - -1. Open File Manager on the **source** host. -2. Select one or more files or folders. -3. Right-click → **Copy to host…** or **Move to host…** (sidebar tree supports the same context menu). -4. In the dialog: - - Choose **destination host** (other connected file-manager hosts). - - Set **destination path** (type a path or use **Browse destination folders**). - - Optionally pick a **recent destination** (collapsed by default). - - For multi-item transfers, choose **transfer method** (Auto / Tar archive / Per-file SFTP); a preview explains what Auto will pick. - - Pin folders with **Add to shortcuts** (per-destination host; uses normal file-manager shortcuts, not a separate favourites list). -5. Confirm **Copy** or **Move**. - -### During transfer - -- A **progress toast** shows phase (compressing, transferring, extracting), bytes, speed, and **Cancel**. -- **Transfer monitor** (desktop, when authenticated) picks up active transfers started in another tab or window. -- Large **single files** use segmented SFTP (256 MiB segments) with **2 parallel lanes** by default (separate dedicated SSH session pairs per lane). The toast can show total speed and lane count when multiple lanes are active. - -### After completion or failure - -| Outcome | What happens | -| ------------- | ---------------------------------------------------------------------------------------------------------------- | -| **Success** | Toast success; destination path saved to **recent destinations** for that source host; file manager can refresh. | -| **Partial** | Some paths failed; toast lists failed paths; source may be kept on move. | -| **Error** | Toast with **Retry** when partial data on destination allows resume. | -| **Cancelled** | Toast with optional **Clean up destination** to remove partial files. | - -**Move** deletes source files only after a successful full transfer (same as copy, then source delete). Cancelled or partial moves may leave files on both sides. - -### Metrics - -On success, expanded timing details can include: prepare destination, compress (tar), per-hop throughput (source→server, server→dest), extract, source delete, total duration. - -## Transfer methods - -| Method | When used | Behavior | -| ------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| **Stream (single file)** | Exactly one file, copy or move | Pipelined SFTP read on source → write on destination; segmented above 32 MiB; parallel lanes for throughput. | -| **Tar archive** | Multi-file/folder when Auto or user selects Tar, and both sides are Unix with `tar` | `tar -czf` on source → one archive streamed through Termix → `tar -xzf` on destination. | -| **Per-file SFTP** | Windows involved, tar unavailable, or Auto chooses it | Each file copied sequentially over SFTP through Termix. | - -**Auto** heuristics (see `src/backend/ssh/transfer-routing.ts`) consider file count, total size, largest file, and compressibility (e.g. many small files → tar; large incompressible sets → per-file SFTP). - -**Method preview** is locked for the current source path set until you change Auto/Tar/Per-file preference, so changing only the destination host does not re-scan the source. - -## Architecture (implementation) - -| Component | Role | -| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts) | Transfer engine: sessions, SFTP pipeline, tar path, retry, cancel, progress. | -| [`src/backend/ssh/file-manager.ts`](../src/backend/ssh/file-manager.ts) | HTTP routes, `openDedicatedTransferSession`, jump/SOCKS connect. | -| [`src/backend/ssh/transfer-routing.ts`](../src/backend/ssh/transfer-routing.ts) | Tar vs per-file SFTP selection. | -| [`src/backend/ssh/transfer-paths.ts`](../src/backend/ssh/transfer-paths.ts) | Path normalization (Unix/Windows). | -| [`src/ui/.../TransferToHostDialog.tsx`](../src/ui/desktop/apps/features/file-manager/components/TransferToHostDialog.tsx) | Transfer dialog. | -| [`src/ui/.../transferProgressMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/transferProgressMonitor.tsx) | Toasts, cancel, retry, cleanup. | -| [`src/ui/.../TransferMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/TransferMonitor.tsx) | Global active-transfer polling. | - -**Sessions:** Browse sessions identify hosts. Each transfer opens **dedicated** SSH sessions (`xfer:{transferId}:src` / `:dst`) so browsing and transfers do not share channels. Parallel lanes add `xfer:{transferId}:src:pN` / `:dst:pN`. - -**Special case:** If the destination host is the **same machine as Termix** (local SSH endpoint), writes use the local filesystem via `fastGet` instead of dest SFTP; data still originates from the remote source through Termix. - -**Persistence:** Recent destinations are stored in `transfer_recent` (per user, per source host). Folder shortcuts use `file_manager_shortcuts` on the destination host. - -## HTTP API (file manager service) - -| Endpoint | Purpose | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST /ssh/file_manager/ssh/transferMethodPreview` | Scan source; return resolved tar vs item_sftp and reason. | -| `POST /ssh/file_manager/ssh/transferToHost` | Start transfer; body includes `sourceSessionId`, `destSessionId`, `sourcePaths`, `destPath`, `move`, `methodPreference`, optional `parallelSegmentCount` (1–8, default 2). | -| `GET /ssh/file_manager/ssh/transferStatus/:transferId` | Poll progress. | -| `GET /ssh/file_manager/ssh/activeTransfers` | List running transfers for user. | -| `POST /ssh/file_manager/ssh/transferCancel/:transferId` | Request cancel. | -| `POST /ssh/file_manager/ssh/transferCleanup/:transferId` | Remove partial destination artifacts after cancel/failure. | -| `POST /ssh/file_manager/ssh/transferRetry/:transferId` | Retry with same snapshot (resume when possible). | - -Database (main API): `GET/POST /host/transfer/recent` for recent destinations. - -## Reliability features - -- **Resume:** Destination file size is probed; SFTP write opens with resume when a partial file exists (per segment on large files). -- **Retry:** Reconnects dedicated sessions; segment-level and full-copy retries with backoff; fresh SSH pairs after repeated failures (lane reset). -- **Stall detection:** ~45 s without progress on a segment; hung transfer/reconnect probing on status polls. -- **Cancel:** Aborts in-flight SFTP; user can clean up destination paths that were created or partially written. -- **Overlap guard:** Refuses transfer when source and destination paths overlap in a destructive way. - -## Limitations - -1. **No direct host-to-host data path** — Termix must reach **both** hosts independently (with each host’s jump/proxy settings). -2. **Not the same as S2S SSH tunnels** — Tunnels in Host Manager forward TCP ports; they do not carry file-manager transfers today. -3. **Throughput** — Remote-to-remote speed is bounded by Termix CPU/RAM and min(Termix↔source, Termix↔dest) links; very large files on a small Termix box may be slower than `scp -3` from a powerful desktop. -4. **Parallel lanes** — Writes are out of order on disk; fine for copy, not for playing media from a partially written file. Default is 2 lanes; UI may not expose lane count (API default applies). -5. **Tar** — Requires `tar` on both Unix hosts; temporary archive under `/tmp` on source during transfer. -6. **Windows** — Tar path disabled; per-file SFTP only for Windows endpoints. -7. **Jump hosts on S2S tunnels** — Server-to-server **tunnel** connect does not use jump hosts; only transfer/browse SSH does. A host reachable only via jump may work for transfer but not as an S2S tunnel source until tunnel code is aligned. - -## Troubleshooting - -| Symptom | Things to check | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| No destination hosts listed | Open File Manager on another host; ensure host has File Manager enabled. | -| Destination “Authentication required” | Connect File Manager on that host once in this session. | -| Transfer fails immediately | SSH from Termix to both hosts (firewall, jump host, SOCKS5, credentials). | -| Slow speed | Termix link to slower side; try off-peak; for single huge files, parallel lanes help if CPU/network allow. | -| Stuck progress | Wait for stall/reconnect; cancel and retry; check server logs for `host_transfer` / `transfer_ssh_*`. | -| Partial files after cancel | Use **Clean up destination** in the toast. | -| 28 GB / 25 GB style progress | Usually parallel progress accounting; status polls use destination size probes. | - -## Comparison to manual `scp` between remotes - -See [Unix & Linux: scp from one remote server to another](https://unix.stackexchange.com/questions/85292/scp-from-one-remote-server-to-another-remote-server). Naive `scp one:file two:file` runs **from the first host** and fails unless that host can SSH to the second. `scp -3` relays through your workstation. **Termix relay** is analogous to `scp -3` through the **Termix server**, with richer lifecycle management, not analogous to direct `ssh source 'scp … dest'`. - ---- - -## Future work: direct routing via tunnels - -The following are **planned / discussed** enhancements, not shipped in the current build. They build on existing **S2S SSH tunnels** (`src/backend/ssh/tunnel.ts`), which already connect **source → endpoint** using `forwardOut` from the source host to the endpoint’s SSH port. - -### Why tunnels matter - -Many homelabs have a destination that is **only reachable from another host** (e.g. NAS on LAN behind a Pi), while Termix runs elsewhere. Today that destination cannot receive a dedicated `xfer:dst` session from Termix even if an S2S tunnel from Pi → NAS is configured and working. - -### Possible routes (future) - -| Route | Termix SSH legs | Data path | Benefit vs today | -| --------------------------------------- | --------------- | --------------------------- | ---------------------------------------------------------------------------------------- | -| **Relay (current)** | 2 | Termix buffers SFTP | Works when both hosts reachable from Termix. | -| **Tunnel-bridged SFTP** | 1 (+ bridge) | Still through Termix memory | Dest reached via source `forwardOut`; fixes reachability; reuses most of current engine. | -| **Direct remote (rsync/scp on source)** | 1 (control) | **Source → dest** bytes | Best throughput; Termix orchestrates `rsync`/`scp` on source when forward + tools allow. | - -### Integration ideas (not implemented) - -1. **`transfer-bridge` module** — Shared `forwardOut` probe and `connectDestThroughSource` (extracted from tunnel code); lookup matching `tunnel_connections` on the source host record. -2. **Route resolver** — Auto-select relay vs bridged vs direct; expose route in method preview (“via Termix” vs “direct host-to-host”). -3. **Reuse active S2S tunnel** — If Host Manager tunnel source→dest is already connected, reuse `endpointClient` instead of opening a second bridge. -4. **Jump hosts on S2S tunnel source** — Align tunnel connect with file-manager jump chains so tunnel and transfer eligibility match. -5. **Fallback** — Always fall back to current relay when probe or remote `rsync` fails (Windows, missing tools, forwarding denied). - -### What would be preserved - -Cancel, partial cleanup, retry/resume (rsync `--partial` on direct path; existing SFTP segment resume on relay/bridged), dedicated sessions, transfer monitor, recent destinations, folder shortcuts, and tar/per-file method selection (with direct-path variants for multi-file). - -### References - -- Internal plan: `.cursor/plans/host-to-host_direct_transfer_*.plan.md` (if present in your checkout). -- Tunnel implementation: [`src/backend/ssh/tunnel.ts`](../src/backend/ssh/tunnel.ts) — `connectEndpointThroughSource`, `establishManagedS2STunnel`. -- Transfer engine: [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts). - ---- diff --git a/readme/README-AR.md b/readme/README-AR.md index 1e8e2085..1a3066a4 100644 --- a/readme/README-AR.md +++ b/readme/README-AR.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا -**إدارة Docker:** -تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها. +**إدارة Docker و Podman:** +تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. يدعم كلاً من Docker و Podman كبيئة تشغيل للحاويات. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها. @@ -115,9 +122,37 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا +**تكامل Tailscale:** +عرض أجهزة شبكتك من Tailscale لإضافتها بسرعة كمضيفات، والاتصال عبر Tailscale SSH كطريقة مصادقة، مما يتيح لقوائم تحكم الوصول في Tailscale التعامل مع التفويض دون الحاجة لتخزين بيانات اعتماد. + + + + **RBAC:** إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار. + + + + + +**الاتصالات التسلسلية:** +الاتصال بالأجهزة التسلسلية (أجهزة التوجيه والمفاتيح والمتحكمات الدقيقة وغيرها) مباشرة من المتصفح أو تطبيق سطح المكتب. ضبط معدل نقل البيانات وبتات البيانات وبتات التوقف والتكافؤ. يستخدم Web Serial API في المتصفحات المدعومة أو خلفية أصلية في تطبيق Electron. + + + + +**التنبيهات:** +ضبط قواعد تنبيه قائمة على الحدود لمقاييس المضيف (المعالج والذاكرة والقرص وغيرها) والحصول على إشعارات عبر ntfy أو webhooks عند إطلاقها. عرض التنبيهات النشطة والمحلولة في سجل التاريخ. + + + + + + +**الصفحة الرئيسية:** +صفحة رئيسية قابلة للتخصيص بالكامل مع شبكة أدوات قابلة للسحب والإفلات. أضف أدوات لحالة المضيف وروابط الخدمات والساعات والملاحظات وخلاصات RSS والطقس وحاويات Docker ومخططات مقاييس المضيف والطرفيات المضمنة والإطارات المضمنة وأكثر. + @@ -255,9 +290,9 @@ networks: ## التبرع -Termix مجاني ومفتوح المصدر بدون اشتراكات أو خطط مدفوعة. إذا وجدته مفيدًا، فكّر في التبرع للمساعدة في تغطية تكاليف الخادم والنطاقات ووقت التطوير. +Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير. -[تبرع](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix مجاني ومفتوح المصدر بدون اشتراكات أو خط Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي. @@ -313,7 +352,7 @@ Termix مجاني ومفتوح المصدر بدون اشتراكات أو خط ## الميزات المخططة -راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/5) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-CN.md b/readme/README-CN.md index 8f0ae44c..c4fa5331 100644 --- a/readme/README-CN.md +++ b/readme/README-CN.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。 + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 -**Docker 管理:** -启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。 +**Docker 和 Podman 管理:** +启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。同时支持 Docker 和 Podman 作为容器运行时。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。 @@ -115,9 +122,37 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 +**Tailscale 集成:** +列出您 Tailscale 网络中的设备以快速添加为主机,并使用 Tailscale SSH 作为身份验证方式,让您的 Tailscale ACL 处理授权而无需存储凭据。 + + + + **RBAC:** 创建角色并在用户/角色之间共享主机。 + + + + + +**串口连接:** +直接从浏览器或桌面应用连接到串口设备(路由器、交换机、微控制器等)。配置波特率、数据位、停止位和奇偶校验。在支持的浏览器中使用 Web Serial API,或在 Electron 应用中使用原生后端。 + + + + +**告警:** +为主机指标(CPU、内存、磁盘等)设置基于阈值的告警规则,并通过 ntfy 或 webhook 接收触发通知。在历史日志中查看触发和已解决的告警。 + + + + + + +**主页:** +具有拖放小组件网格的完全可定制主页。添加主机状态、服务链接、时钟、笔记、RSS 订阅、天气、Docker 容器、主机指标图表、嵌入式终端、iframe 等小组件。 + @@ -255,9 +290,9 @@ networks: ## 捐赠 -Termix 是免费且开源的,没有订阅或付费计划。如果您觉得它有用,请考虑捐赠以帮助支付服务器费用、域名费用和开发时间。 +Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。 -[捐赠](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix 是免费且开源的,没有订阅或付费计划。如果您觉得它 Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + 某些视频和图像可能已过时,或者可能无法完美展示功能。 @@ -313,7 +352,7 @@ Termix 是免费且开源的,没有订阅或付费计划。如果您觉得它 ## 计划功能 -查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。 +查看 [Projects](https://github.com/orgs/Termix-SSH/projects/5) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
diff --git a/readme/README-DE.md b/readme/README-DE.md index 5c2d5514..deb0836f 100644 --- a/readme/README-DE.md +++ b/readme/README-DE.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeig -**Docker-Verwaltung:** -Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. +**Docker- und Podman-Verwaltung:** +Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Unterstutzt sowohl Docker als auch Podman als Container-Laufzeitumgebung. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. @@ -115,9 +122,37 @@ Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzun +**Tailscale-Integration:** +Gerate aus Ihrem Tailnet auflisten, um sie schnell als Hosts hinzuzufugen, und mit Tailscale SSH als Authentifizierungsmethode verbinden, sodass Ihre Tailnet-ACLs die Autorisierung ubernehmen, ohne Anmeldedaten speichern zu mussen. + + + + **RBAC:** Rollen erstellen und Hosts uber Benutzer/Rollen teilen. + + + + + +**Serielle Verbindungen:** +Verbinden Sie sich direkt vom Browser oder der Desktop-App aus mit seriellen Geraten (Router, Switches, Mikrocontroller usw.). Konfigurieren Sie Baudrate, Datenbits, Stoppbits und Paritat. Verwendet die Web Serial API in unterstutzten Browsern oder ein natives Backend in der Electron-App. + + + + +**Warnmeldungen:** +Legen Sie schwellenwertbasierte Warnregeln fur Host-Metriken (CPU, Arbeitsspeicher, Festplatte usw.) fest und erhalten Sie Benachrichtigungen uber ntfy oder Webhooks, wenn diese ausgelost werden. Zeigen Sie ausgeloste und aufgeloste Warnmeldungen in einem Verlaufsprotokoll an. + + + + + + +**Startseite:** +Eine vollstandig anpassbare Startseite mit einem Drag-and-Drop-Widget-Raster. Fugen Sie Widgets fur Hoststatus, Service-Links, Uhren, Notizen, RSS-Feeds, Wetter, Docker-Container, Host-Metrik-Diagramme, eingebettete Terminals, iFrames und mehr hinzu. + @@ -255,9 +290,9 @@ networks: ## Spenden -Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Pläne. Wenn Sie es nützlich finden, erwägen Sie eine Spende, um Serverkosten, Domains und Entwicklungszeit zu decken. +Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken. -[Spenden](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Plä Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Einige Videos und Bilder konnen veraltet sein oder Funktionen moglicherweise nicht perfekt darstellen. @@ -313,7 +352,7 @@ Termix ist kostenlos und Open Source ohne Abonnements oder kostenpflichtige Plä ## Geplante Funktionen -Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/5) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-ES.md b/readme/README-ES.md index 6b164c21..041d3f15 100644 --- a/readme/README-ES.md +++ b/readme/README-ES.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Gestione archivos directamente en servidores remotos con soporte para visualizar -**Gestion de Docker:** -Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. +**Gestion de Docker y Podman:** +Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. Compatible con Docker y Podman como entorno de ejecucion de contenedores. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. @@ -115,9 +122,37 @@ Gestion segura de usuarios con controles de administrador y soporte para OIDC/LD +**Integracion con Tailscale:** +Liste dispositivos de su red Tailscale para agregarlos rapidamente como hosts y conectese usando Tailscale SSH como metodo de autenticacion, permitiendo que las ACL de Tailscale gestionen la autorizacion sin almacenar credenciales. + + + + **RBAC:** Cree roles y comparta hosts entre usuarios/roles. + + + + + +**Conexiones Serie:** +Conectese a dispositivos serie (routers, switches, microcontroladores, etc.) directamente desde el navegador o la aplicacion de escritorio. Configure la tasa de baudios, bits de datos, bits de parada y paridad. Utiliza la Web Serial API en navegadores compatibles o un backend nativo en la aplicacion Electron. + + + + +**Alertas:** +Configure reglas de alerta basadas en umbrales para metricas del host (CPU, memoria, disco, etc.) y reciba notificaciones a traves de ntfy o webhooks cuando se activen. Vea las alertas activas y resueltas en un historial de registros. + + + + + + +**Pagina de Inicio:** +Una pagina de inicio completamente personalizable con una cuadricula de widgets de arrastrar y soltar. Agregue widgets para estado del host, enlaces de servicios, relojes, notas, feeds RSS, clima, contenedores Docker, graficos de metricas del host, terminales integrados, iframes y mas. + @@ -255,9 +290,9 @@ networks: ## Donar -Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si lo encuentras útil, considera hacer una donación para ayudar a cubrir los costos del servidor, dominios y tiempo de desarrollo. +Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo. -[Donar](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Algunos videos e imagenes pueden estar desactualizados o no mostrar perfectamente las caracteristicas. @@ -313,7 +352,7 @@ Termix es gratuito y de código abierto sin suscripciones ni planes de pago. Si ## Caracteristicas Planeadas -Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/5) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-FR.md b/readme/README-FR.md index 0915f825..c32ae6ba 100644 --- a/readme/README-FR.md +++ b/readme/README-FR.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Gerez les fichiers directement sur les serveurs distants avec support de la visu -**Gestion Docker:** -Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer. +**Gestion Docker et Podman:** +Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Compatible avec Docker et Podman comme environnement d'execution de conteneurs. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer. @@ -115,9 +122,37 @@ Gestion securisee des utilisateurs avec controles administrateur et support OIDC +**Integration Tailscale:** +Listez les appareils de votre reseau Tailscale pour les ajouter rapidement comme hotes, et connectez-vous en utilisant Tailscale SSH comme methode d'authentification, laissant les ACL de votre reseau gerer l'autorisation sans stocker de credentials. + + + + **RBAC:** Creez des roles et partagez des hotes entre utilisateurs/roles. + + + + + +**Connexions Serie:** +Connectez-vous a des appareils serie (routeurs, commutateurs, microcontroleurs, etc.) directement depuis le navigateur ou l'application bureau. Configurez le debit en bauds, les bits de donnees, les bits d'arret et la parite. Utilise l'API Web Serial dans les navigateurs compatibles ou un backend natif dans l'application Electron. + + + + +**Alertes:** +Definissez des regles d'alerte basees sur des seuils pour les metriques d'hote (CPU, memoire, disque, etc.) et recevez des notifications via ntfy ou webhooks lorsqu'elles se declenchent. Consultez les alertes actives et resolues dans un journal d'historique. + + + + + + +**Page d'accueil:** +Une page d'accueil entierement personnalisable avec une grille de widgets glisser-deposer. Ajoutez des widgets pour l'etat des hotes, les liens de services, les horloges, les notes, les flux RSS, la meteo, les conteneurs Docker, les graphiques de metriques d'hote, les terminaux integres, les iframes et plus encore. + @@ -255,9 +290,9 @@ networks: ## Faire un don -Termix est gratuit et open source sans abonnements ni plans payants. Si vous le trouvez utile, pensez à faire un don pour aider à couvrir les coûts de serveur, les noms de domaine et le temps de développement. +Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement. -[Faire un don](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix est gratuit et open source sans abonnements ni plans payants. Si vous le Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Certaines videos et images peuvent etre obsoletes ou ne pas presenter parfaitement les fonctionnalites. @@ -313,7 +352,7 @@ Termix est gratuit et open source sans abonnements ni plans payants. Si vous le ## Fonctionnalites prevues -Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/5) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-HI.md b/readme/README-HI.md index 04e83adc..d88917de 100644 --- a/readme/README-HI.md +++ b/readme/README-HI.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)। + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु -**Docker प्रबंधन:** -कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है। +**Docker और Podman प्रबंधन:** +कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। Docker और Podman दोनों को कंटेनर रनटाइम के रूप में सपोर्ट करता है। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है। @@ -115,9 +122,37 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु +**Tailscale एकीकरण:** +अपने Tailscale नेटवर्क के डिवाइस सूचीबद्ध करें ताकि उन्हें जल्दी से होस्ट के रूप में जोड़ा जा सके, और Tailscale SSH को प्रमाणीकरण विधि के रूप में उपयोग करके कनेक्ट करें, जिससे आपके Tailscale ACL क्रेडेंशियल संग्रहीत किए बिना प्राधिकरण संभाल सकें। + + + + **RBAC:** भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें। + + + + + +**सीरियल कनेक्शन:** +सीरियल डिवाइस (राउटर, स्विच, माइक्रोकंट्रोलर आदि) से सीधे ब्राउज़र या डेस्कटॉप ऐप से कनेक्ट करें। बॉड रेट, डेटा बिट्स, स्टॉप बिट्स और पैरिटी कॉन्फ़िगर करें। समर्थित ब्राउज़र में Web Serial API या Electron ऐप में नेटिव बैकएंड का उपयोग करता है। + + + + +**अलर्ट:** +होस्ट मेट्रिक्स (CPU, मेमोरी, डिस्क आदि) पर थ्रेशोल्ड-आधारित अलर्ट नियम सेट करें और जब वे ट्रिगर हों तो ntfy या webhooks के माध्यम से सूचना पाएँ। इतिहास लॉग में सक्रिय और हल किए गए अलर्ट देखें। + + + + + + +**होमपेज:** +ड्रैग-एंड-ड्रॉप विजेट ग्रिड के साथ पूरी तरह से कस्टमाइज़ करने योग्य होमपेज। होस्ट स्टेटस, सर्विस लिंक, घड़ियाँ, नोट्स, RSS फ़ीड, मौसम, Docker कंटेनर, होस्ट मेट्रिक्स चार्ट, एम्बेडेड टर्मिनल, iframes और अन्य के लिए विजेट जोड़ें। + @@ -255,9 +290,9 @@ networks: ## दान करें -Termix मुफ़्त और ओपन सोर्स है, इसमें कोई सदस्यता या भुगतान योजना नहीं है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत, डोमेन और विकास समय को कवर करने में मदद के लिए दान करने पर विचार करें। +Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)। -[दान करें](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix मुफ़्त और ओपन सोर्स है, इसमे Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं। @@ -313,7 +352,7 @@ Termix मुफ़्त और ओपन सोर्स है, इसमे ## नियोजित विशेषताएँ -सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें। +सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/5) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
diff --git a/readme/README-IT.md b/readme/README-IT.md index 2f622b22..278430ae 100644 --- a/readme/README-IT.md +++ b/readme/README-IT.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio -**Gestione Docker:** -Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. +**Gestione Docker e Podman:** +Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Supporta sia Docker che Podman come runtime dei container. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. @@ -115,9 +122,37 @@ Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (co +**Integrazione Tailscale:** +Elenca i dispositivi della tua rete Tailscale per aggiungerli rapidamente come host, e connettiti utilizzando Tailscale SSH come metodo di autenticazione, lasciando che le ACL della tua rete gestiscano l'autorizzazione senza memorizzare credenziali. + + + + **RBAC:** Crea ruoli e condividi host tra utenti/ruoli. + + + + + +**Connessioni Seriali:** +Connettiti a dispositivi seriali (router, switch, microcontrollori, ecc.) direttamente dal browser o dall'app desktop. Configura baud rate, bit di dati, bit di stop e parita. Utilizza la Web Serial API nei browser supportati o un backend nativo nell'app Electron. + + + + +**Avvisi:** +Imposta regole di avviso basate su soglie per le metriche dell'host (CPU, memoria, disco, ecc.) e ricevi notifiche tramite ntfy o webhook quando si attivano. Visualizza gli avvisi attivi e risolti in un registro storico. + + + + + + +**Homepage:** +Una homepage completamente personalizzabile con una griglia di widget drag-and-drop. Aggiungi widget per lo stato dell'host, link ai servizi, orologi, note, feed RSS, meteo, container Docker, grafici delle metriche dell'host, terminali incorporati, iframe e altro ancora. + @@ -255,9 +290,9 @@ networks: ## Dona -Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo trovi utile, considera di fare una donazione per aiutare a coprire i costi del server, i domini e il tempo di sviluppo. +Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo. -[Dona](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo tr Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita. @@ -313,7 +352,7 @@ Termix è gratuito e open source senza abbonamenti o piani a pagamento. Se lo tr ## Funzionalita Pianificate -Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/5) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-JA.md b/readme/README-JA.md index 83b142fd..8980fcae 100644 --- a/readme/README-JA.md +++ b/readme/README-JA.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。 + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termixは、オープンソースで永久無料のセルフホスト型オー -**Docker管理:** -コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。 +**DockerおよびPodman管理:** +コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。DockerとPodmanの両方をコンテナランタイムとしてサポートしています。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。 @@ -115,9 +122,37 @@ Termixは、オープンソースで永久無料のセルフホスト型オー +**Tailscaleインテグレーション:** +TailnetのデバイスをリストしてホストとしてすばやくH追加し、Tailscale SSHを認証方法として使用して接続します。これにより、TailnetのACLが認証情報を保存せずに認可を処理します。 + + + + **RBAC:** ロールを作成し、ユーザー/ロール間でホストを共有できます。 + + + + + +**シリアル接続:** +ブラウザまたはデスクトップアプリからシリアルデバイス(ルーター、スイッチ、マイクロコントローラーなど)に直接接続できます。ボーレート、データビット、ストップビット、パリティを設定できます。対応ブラウザではWeb Serial APIを使用し、Electronアプリではネイティブバックエンドを使用します。 + + + + +**アラート:** +ホストメトリクス(CPU、メモリ、ディスクなど)に対してしきい値ベースのアラートルールを設定し、発動時にntfyまたはwebhookで通知を受け取れます。発動中および解決済みのアラートを履歴ログで確認できます。 + + + + + + +**ホームページ:** +ドラッグ&ドロップのウィジェットグリッドを備えた完全カスタマイズ可能なホームページ。ホストステータス、サービスリンク、時計、メモ、RSSフィード、天気、Dockerコンテナ、ホストメトリクスグラフ、埋め込みターミナル、iframeなどのウィジェットを追加できます。 + @@ -255,9 +290,9 @@ networks: ## 寄付 -Termix はサブスクリプションや有料プランのない、無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコスト、ドメイン、開発時間をカバーするための寄付をご検討ください。 +Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。 -[寄付する](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix はサブスクリプションや有料プランのない、無料のオ Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + 動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。 @@ -313,7 +352,7 @@ Termix はサブスクリプションや有料プランのない、無料のオ ## 予定されている機能 -すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。 +すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/5)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
diff --git a/readme/README-KO.md b/readme/README-KO.md index 7fd2891d..4fee63ca 100644 --- a/readme/README-KO.md +++ b/readme/README-KO.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 -**Docker 관리:** -컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다. +**Docker 및 Podman 관리:** +컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Docker와 Podman을 모두 컨테이너 런타임으로 지원. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다. @@ -115,9 +122,37 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 +**Tailscale 통합:** +Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가하고, Tailscale SSH를 인증 방법으로 사용하여 연결함으로써 자격 증명을 저장하지 않고도 네트워크 ACL이 권한 부여를 처리하도록 합니다. + + + + **RBAC:** 역할을 생성하고 사용자/역할 간에 호스트 공유. + + + + + +**시리얼 연결:** +브라우저 또는 데스크톱 앱에서 직접 시리얼 장치(라우터, 스위치, 마이크로컨트롤러 등)에 연결. 보드레이트, 데이터 비트, 스톱 비트, 패리티 구성. 지원 브라우저에서는 Web Serial API를, Electron 앱에서는 네이티브 백엔드를 사용합니다. + + + + +**알림:** +호스트 메트릭(CPU, 메모리, 디스크 등)에 대한 임계값 기반 알림 규칙을 설정하고 트리거될 때 ntfy 또는 웹훅을 통해 알림 수신. 기록 로그에서 발생 중인 알림과 해결된 알림 확인. + + + + + + +**홈페이지:** +드래그 앤 드롭 위젯 그리드를 갖춘 완전 맞춤형 홈페이지. 호스트 상태, 서비스 링크, 시계, 메모, RSS 피드, 날씨, Docker 컨테이너, 호스트 메트릭 차트, 임베디드 터미널, iframe 등의 위젯 추가 가능. + @@ -255,9 +290,9 @@ networks: ## 후원 -Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용, 도메인, 개발 시간을 지원하기 위한 후원을 고려해 주세요. +Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요. -[후원하기](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입 Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + 일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다. @@ -313,7 +352,7 @@ Termix는 구독이나 유료 플랜 없이 무료 오픈소스 프로젝트입 ## 계획된 기능 -모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요. +모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/5)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
diff --git a/readme/README-PT.md b/readme/README-PT.md index 5325d6d9..47656bbd 100644 --- a/readme/README-PT.md +++ b/readme/README-PT.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Gerencie arquivos diretamente em servidores remotos com suporte para visualizar -**Gerenciamento de Docker:** -Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los. +**Gerenciamento de Docker e Podman:** +Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Suporta Docker e Podman como ambiente de execucao de conteineres. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los. @@ -115,9 +122,37 @@ Gerenciamento seguro de usuarios com controles de administrador e suporte para O +**Integracao com Tailscale:** +Liste dispositivos da sua rede Tailscale para adicioná-los rapidamente como hosts, e conecte-se usando Tailscale SSH como metodo de autenticacao, deixando as ACLs da sua rede gerenciar a autorizacao sem armazenar credenciais. + + + + **RBAC:** Crie funcoes e compartilhe hosts entre usuarios/funcoes. + + + + + +**Conexoes Seriais:** +Conecte-se a dispositivos seriais (roteadores, switches, microcontroladores, etc.) diretamente do navegador ou do aplicativo desktop. Configure taxa de baud, bits de dados, bits de parada e paridade. Usa a Web Serial API em navegadores suportados ou um backend nativo no aplicativo Electron. + + + + +**Alertas:** +Defina regras de alerta baseadas em limites para metricas do host (CPU, memoria, disco, etc.) e receba notificacoes via ntfy ou webhooks quando forem ativadas. Visualize alertas ativos e resolvidos em um historico de registros. + + + + + + +**Pagina Inicial:** +Uma pagina inicial totalmente personalizavel com uma grade de widgets de arrastar e soltar. Adicione widgets para status do host, links de servicos, relogios, notas, feeds RSS, clima, conteineres Docker, graficos de metricas do host, terminais incorporados, iframes e mais. + @@ -255,9 +290,9 @@ networks: ## Doar -O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o achar útil, considere fazer uma doação para ajudar a cobrir os custos de servidor, domínios e tempo de desenvolvimento. +Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento. -[Doar](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Alguns videos e imagens podem estar desatualizados ou podem nao mostrar perfeitamente as funcionalidades. @@ -313,7 +352,7 @@ O Termix é gratuito e de código aberto, sem assinaturas ou planos pagos. Se o ## Funcionalidades Planejadas -Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/5) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-RU.md b/readme/README-RU.md index 8bc04f24..2ed46ed4 100644 --- a/readme/README-RU.md +++ b/readme/README-RU.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Termix - это платформа для управления серверам -**Управление Docker:** -Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием. +**Управление Docker и Podman:** +Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Поддерживает как Docker, так и Podman в качестве среды выполнения контейнеров. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием. @@ -115,9 +122,37 @@ Termix - это платформа для управления серверам +**Интеграция с Tailscale:** +Список устройств вашей сети Tailscale для быстрого добавления их в качестве хостов и подключение через Tailscale SSH в качестве метода аутентификации, позволяя ACL вашей сети управлять авторизацией без хранения учётных данных. + + + + **RBAC:** Создание ролей и предоставление общего доступа к хостам для пользователей/ролей. + + + + + +**Последовательные подключения:** +Подключение к последовательным устройствам (маршрутизаторы, коммутаторы, микроконтроллеры и т. д.) напрямую из браузера или приложения для рабочего стола. Настройка скорости передачи данных, битов данных, стоп-битов и чётности. Использует Web Serial API в поддерживаемых браузерах или нативный бэкенд в приложении Electron. + + + + +**Оповещения:** +Настройте правила оповещений на основе пороговых значений для метрик хоста (CPU, память, диск и т. д.) и получайте уведомления через ntfy или вебхуки при их срабатывании. Просматривайте активные и разрешённые оповещения в журнале истории. + + + + + + +**Домашняя страница:** +Полностью настраиваемая домашняя страница с сеткой виджетов с перетаскиванием. Добавляйте виджеты для статуса хоста, ссылок на сервисы, часов, заметок, RSS-лент, погоды, контейнеров Docker, графиков метрик хоста, встроенных терминалов, iframe и многого другого. + @@ -255,9 +290,9 @@ networks: ## Пожертвование -Termix — бесплатный проект с открытым исходным кодом без подписок и платных планов. Если вы находите его полезным, рассмотрите возможность пожертвования для покрытия расходов на серверы, домены и время разработки. +Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки. -[Пожертвовать](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix — бесплатный проект с открытым исходны Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. @@ -313,7 +352,7 @@ Termix — бесплатный проект с открытым исходны ## Запланированные функции -Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/5) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/readme/README-TR.md b/readme/README-TR.md index e11b6a7d..571d0cc8 100644 --- a/readme/README-TR.md +++ b/readme/README-TR.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video gorunt -**Docker Yonetimi:** -Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir. +**Docker ve Podman Yonetimi:** +Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Docker ve Podman'i konteyner calisma ortami olarak destekler. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir. @@ -115,9 +122,37 @@ Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile +**Tailscale Entegrasyonu:** +Tailscale aginizdaki cihazlari listeleyerek hizlica ana bilgisayar olarak ekleyin ve kimlik dogrulama yontemi olarak Tailscale SSH kullanarak baglanin; bu sayede ag ACL'leriniz kimlik bilgileri depolamadan yetkilendirmeyi yonetir. + + + + **RBAC:** Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin. + + + + + +**Seri Baglantilar:** +Seri cihazlara (router, switch, mikrodenetleyici vb.) dogrudan tarayici veya masaustu uygulamasindan baglanin. Baud hizi, veri bitleri, durdurma bitleri ve parite yapilandirin. Desteklenen tarayicilarda Web Serial API, Electron uygulamasinda yerel arka ucu kullanir. + + + + +**Uyarilar:** +Ana bilgisayar metrikleri (CPU, bellek, disk vb.) icin esik tabanli uyari kurallari belirleyin ve tetiklendiklerinde ntfy veya webhook araciligiyla bildirim alin. Gecmis gunlugunde tetiklenen ve cozulen uyarilari goruntuleyin. + + + + + + +**Ana Sayfa:** +Surukleme ve birakma widget izgarasina sahip tamamen ozerlestirilebilir bir ana sayfa. Ana bilgisayar durumu, hizmet baglantilari, saatler, notlar, RSS besleme, hava durumu, Docker konteynerleri, ana bilgisayar metrik grafikleri, gomulu terminaller, iframe ve daha fazlasi icin widget ekleyin. + @@ -255,9 +290,9 @@ networks: ## Bağış Yapın -Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir projedir. Faydalı buluyorsanız, sunucu maliyetleri, alan adları ve geliştirme süresini karşılamaya yardımcı olmak için bağış yapmayı düşünebilirsiniz. +Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz. -[Bağış Yap](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir pr Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Bazi videolar ve gorseller guncel olmayabilir veya ozellikleri tam olarak yansitmayabilir. @@ -313,7 +352,7 @@ Termix, abonelik veya ücretli plan olmayan ücretsiz ve açık kaynaklı bir pr ## Planlanan Ozellikler -Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin. +Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/5) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin.
diff --git a/readme/README-VI.md b/readme/README-VI.md index 7779af67..c0038d8c 100644 --- a/readme/README-VI.md +++ b/readme/README-VI.md @@ -28,10 +28,17 @@ Discord + Donate


+Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển. + +Monthly donation goal + +
+ Termix Banner
@@ -87,8 +94,8 @@ Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh an -**Quan Ly Docker:** -Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung. +**Quan Ly Docker va Podman:** +Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Ho tro ca Docker va Podman lam moi truong chay container. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung. @@ -115,9 +122,37 @@ Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem s +**Tich Hop Tailscale:** +Liet ke cac thiet bi trong mang Tailscale de nhanh chong them vao lam may chu, va ket noi bang Tailscale SSH lam phuong thuc xac thuc, de cac ACL mang xu ly uy quyen ma khong can luu tru thong tin xac thuc. + + + + **RBAC:** Tao vai tro va chia se may chu giua nguoi dung/vai tro. + + + + + +**Ket Noi Noi Tiep:** +Ket noi voi cac thiet bi noi tiep (router, switch, vi dieu khien, v.v.) truc tiep tu trinh duyet hoac ung dung may tinh. Cau hinh toc do baud, bit du lieu, bit dung va chan le. Su dung Web Serial API tren trinh duyet duoc ho tro hoac backend ban dia trong ung dung Electron. + + + + +**Canh Bao:** +Dat cac quy tac canh bao dua tren nguong cho chi so may chu (CPU, bo nho, o dia, v.v.) va nhan thong bao qua ntfy hoac webhook khi chung khi toa. Xem canh bao dang kich hoat va da giai quyet trong nhat ky lich su. + + + + + + +**Trang Chu:** +Trang chu co the tuy chinh hoan toan voi luoi widget keo va tha. Them widget cho trang thai may chu, lien ket dich vu, dong ho, ghi chu, feed RSS, thoi tiet, container Docker, bieu do chi so may chu, terminal nhung, iframe va nhieu hon nua. + @@ -255,9 +290,9 @@ networks: ## Quyên góp -Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký hay trả phí. Nếu bạn thấy hữu ích, hãy cân nhắc quyên góp để giúp trang trải chi phí máy chủ, tên miền và thời gian phát triển. +Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển. -[Quyên góp](https://donate.termix.site/) +Monthly donation goal
@@ -303,6 +338,10 @@ Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký Termix Screenshot 13 Termix Screenshot 14 + +Termix Screenshot 15 +Termix Screenshot 16 + Mot so video va hinh anh co the da loi thoi hoac khong the hien chinh xac hoan toan cac tinh nang. @@ -313,7 +352,7 @@ Termix là dự án miễn phí và mã nguồn mở, không có gói đăng ký ## Tinh Nang Du Kien -Xem [Du An](https://github.com/orgs/Termix-SSH/projects/2) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). +Xem [Du An](https://github.com/orgs/Termix-SSH/projects/5) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
diff --git a/repo-images/Image 15.png b/repo-images/Image 15.png new file mode 100644 index 0000000000000000000000000000000000000000..4180e3ba24ec6c2ae04a46cdc3e8ce81019bf791 GIT binary patch literal 539961 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

jYWJ?q4Ka z8`HhcqokfRwzlcMrE}P!Z)w7?PoN!J_RFW7U34darbAd*#ff8nMY+95<(|6jlHx>$ z<{&cuKIXJKg|5lgl-(@pC!nN)0ah^=H}sZ{X|N8=!MAINmR=IPOs18-x=yxV7z?@S z|J;Yz+57(Ie-8y~kC*hgf1iDMhwYsfVIqNrM<{={X=f)XeyaFeLQ;DAlV6Y9Rs+X| zD;otUD_qu82BfV-JGOH>&H@LB0oLV+Z2T~``)u$u7}<_@7yVntr6#tSn&362qR;*! zpXe4+biMpe51T(;{xr_zQ&bE1_;%*H0G=t2CLb{_^1ctgg_qE1E%=JGC9Mb<{s7ty z`*vYqQR2wccFrCizvnu(Z{w0GizsAr?QGj~$Bv?d02IZQwNlw3A)$325hB108s7ME z=V9T&w#q*o+w#ggYj&tvQrhy4J$I=HVs*>((KmumlK~&DJMU&7X+_D+8j;r&XpP&O zC98yjX)#;`F%HoB8W{Tn?Vsk-UeHRvf6fr!*W2MJBe8MxSo38W{Ich!9oeJ#jr!p0MZJTtUHNtFn>+R<7w09%`Fwu$ft!-+)On4aFB_(hkdc-S16@6k`7@y?fECLvC&1Cs z;~LFD@^FbHdaQ-*D=~fY>Y0iMFC;Ulnd)#c{*rRrA;?p|`ZCU=?~$!hLKDZY;vs=y zz)8~>&d8yo@51IX?s_Z4ELZ@NKXMhTj@Lgb2&HkVAWu>sPk5uH7{aU@X|SW;v} zT0CGQzzF7}rNX=gI5tJHDMnd0{7CXKdMWGl0y%Bjq1c#XZnQ7Q)3bZQ#oXTvMeQku z+MY{0=0Zp4SbYxI0UB0!0N1O(z?LPvrcb`n z0}xRFd>ExrnjFZsGxW`<0S4z)M0#rK=Jag9lGm*(Z;F9~!vL?tb&QJnpM$#vwih+t zeNN#pzTMbLHlG6sTQ<$)kj>yA0MhA!QOfS&>?p@pE-)LbOUoT8_4D%uFBdf~g3n2# zuOp;{`!Y+@RtR5d4#$=-4_93Om_Sc6%^nKVlwf3Y*=GjwkzDX-31WD;P%f8IN#G`X z|HZ3Jn3`9Y5=YUE@gJj3d{})sW-LZ&6xYe(NV*eJTe|u`IU%7%&ewYsbJ?S@@@TS*MKix4qirQP zYEGl<9(d?He89`qMVoex2$GLNJ;uvxs_e@?|@ZL+nqrBhM#b8n(^9ZC~_3Yi~kO* z#)f_+lO+SinI*5KWPuk7G(==(D_a=r(#lV;^ON;Yes>id2vxm2yo)X9+Q@lzrDPBy?ptjyFDemK9cG#eumm(qi3#?>q|7HWr;wjB z_q6H6QYc_u829L~a3B>6{frgsLS|H|M|aGqD09`i(8b^bhly!1Z!k>h zzlGsVTYtI9#i23$zF5i%wbY_RT6_`+rmH`J6beA=2jdkm);I2y(58j-nu06dbCZLb zI?zA(-=mwFHeu$A7SjG=Wv0>0Srx~D4$bE}-#70<<$t@<;qg!5%oJL5tRTt-90q-i z1-6AN)J>SIa4N90O@S)B$R_(Dcg3Ocxf)MX!?|cPGgHzpodpZp-=gs2l{@|-y81EW z)QC;}_x{XVA&SmWRWgE}gZco}19dAnvy-oO%(7|{%Qt;#_I`hPf%yM~YwFn*AIxD@S`mJ$G~6EDcwk`2>brT9v>=s(_ZQc7K){AM^`xZ$$x`?J}HZ0D5;B5MG( zcE$9!;zfna^b2k{01F{ezHdI57$hlglx%iGX*q-t;!KG5N(?^8dV^YwwdtZtMQcTu zSZnIrss9Opp}%2t4}kB15C`q?Cs4t&# znMh-qHQiNBhsEG#C%x%x$_s{d=mg*?b+RKsL;qjNv+S>xY(9x2L#@FxzMnxDRJ(#1 z`-=L@#9>l_V>;#LucPKNuQKH+_c_Z~%?R-3MnZ6qzw`yScUy*IO0?gp_&&J?!l4W~ z6n~N^JRfG%GKl^Nqk8njaiI@@nRh$OXMC7?n>sY8)FyH7K&v%@5(yk!vUZw_;L1jP zRUec=^i4BfbOeGu!6yu0@vDThS36O0q+M!TxchY^uu=iSPrGTrq)$_GmoP0bf{KGy zp?c|%z*k;wqJ752jY&0f22A{PjU2$=AmUk6Yv{|doe;NN1?qE};e%+1vrf3zUZO`r zGgh|wALx`3>=zhW4Fq=+vqXauT24yM)zQ+l0EHt%IlyjSAfyiE_`8tWs7VcV6KbrY ztN1YoHCmChnQ<$2W8~t8 z_5YRS#seH5n`3tFpHm-1O8JZbsfe68mp}bQD^LR>^`<5Zr2_2SM5d?8#o+LDH(9AqFT^{9e z$Nw~Ut4VNcXr){LlRyyXnQ=9OL`Ih9v6Cl7o$&{MMAGF74tIhOZ+DMkQSUach)`77 z?RRm%MLD0z8mirN^|l`zN0i$d3$Q^|o}_j!s6#@=rjl-G1}`o?iRVJu+Rm0-h~nQ} z4)QYr9#w*2S~#h6N$eUDDehk!P~kfV6wQQbHaq_7d)^=FL?w6(jE#-m2Ez<%qL)Vz zV7M*~-HEU&aopvAg-#CI>)nqUCrcOC---q1flrlc_rVOasoEZT6>ci;S~_U5MWC{&)_6Msd%L%fF{xaoN8_=r-qg94Y<(&Cd!&k3+DIHT6 zqwId?HK`cObw%Av#BrAE#T%4XZZ)?7383LqeC%G(*M#4{ztKfU%gHHr#{f-B*BuWT zv`{&wKxGf1>+J&b*sK?M)<3~>kuM7!m7BMl9KW!HPy_-)WrH*^%bBo0pqA9 z)p#e-2}D?%Hv<>R1jKE6diCKmGh^WMFZ3Up%HeM}A^SUBaeY0Sz8{+ z$5m(=7}Va`z;DyU=pu4%l~N{F|{n!kURO$on z&J`2}@)d#?=5f%9KK%7K7aRn-@ey$E{R!RzV4HkS&R-dJ08&g&O(mqKBZGMPR&|N# zK!#3+iP8JyNl$TA1TfC+f!8tA%B~HTDLAlL=mDh5i~SGI{i>lNv&x?WilunH-{`Dd zdy$VjoIW7E8>ZrvZl2CP;j~eGeH|ZcsUJOZdt$buMH@AB2$&Y3C|W~Z1Aj;bz|B>c16Yn4mQqbeQDE4^Yz0xqKoAVnC85c<*y#2y@3e;r8X5XecS|TCAVj zhQedX^D!|o7Ra<3kfBZ>>7m-90z<5`=*ilTVZf~L$x)cIFvc4~CPTY%I}%NF18=6u z&#%wJ4T@~Fe!c7m9+=loS_Qq!0cX-0*sijG<}euZg2QzTmpBh!58~)JHNV^=W_i_q zh&Xw4IFRmthsNd`LLDl8rBoj1>-+VZx%pTzaN4M=IS_E*Aml8iOeWb6g|gFT-ECc+z{BH!wA)$oSH0VQ|DD(Ej4dDI<=4Ftl|eg6MY|!xepy- zVu|!pk_%Om3;KB%)H_VZ^f9Ov8VRLmi~#aXVy&=Eyl9o~?rw0XcK=Cc3h9DenQ8IlR8Yp> zT!0xCP;cSRa3e~8M1Nz_DglUv3@a_LhGT=vUP-~*b0DA%lrQB9LSe7RqFVJ^Nzy8@ zlzC8Wr?EhTw{_ka+}(^@d;uqV)c7|KWtAWHP#y6>fQg$PCa1Su(Iv7$Z7^j<@>ZI{ue44GS6qYl1c)6D`fz1xff*8yIgN@jI^+Hw|vRI z@)%Toz{8T=;k8P!BCG;R&cEpYOT;?}Oe^HG0l}+tTQ-2E623<0w*|8UD+D5@paK-R z!q-ho{PQ;p2wXHZMZ+Z$Iznb2iw}NkhL{oC1cGWAqftL3mt{5on`2An`!|w>AGAw^ zHm#~4S7}(l>{_W>Q#fAy(K(V5>i$ZAD$z@^vO88)&B-u&4^;n0Wg9lL1*h4yS}LY+ z>0B^M8wsH(p{+1ufG&yk4r)Yy#+k-()sPiYqxod0Gr8AqYsTM9sIi5L!z=PlE`f^; znO(&1(XUFDp{NDz%s<&zbUr`0B>Df6PqG4;{`Y}p<*EG@PJ??r;j6p&{Ki(~;}e)A z)s<4*Yl#$a;9k>}$NwRsq%Tz*=5{5>*1^lBX94SivK1!KUor~`<(OKP{ci6Dn<;*Pp^uRC&r;-YeU(9RRtepi;EHd770d|H2Rxg_DDr; zg_0_KYUI^!6#rAvv@(^~SeAiP9v7o0L#tUsG2XU_pf*tr)Bi$%t%wx3lZ5j6zot}TLqmxu;<5F}))DTtLukF(> zBFlf81dS;o37xSxaGP(S{GRw`(#`J4aKa!MwHS^21KV2%_Hu6s@CM&5mAQ|Kj7=on zPwb>~UI4$)ooSg2dAX=0kzLPbpDp**!i@=%Bpw`jUB^EFtotPy>NcB6& z?rO-OGMk{tnFRS567(DZc$=@JT=j=)2s-og4FAYT=&Y@fZCyWwQd|FibUZJKQGRo7 z3g_@--I?vZMu%kOc~Z;U4yWhsnay4{4+zlwc~l>)YS{0tWOQGg{d|6T|4%f@DbOU% zzPsg|!jmb2>$@*Cuq2T;^DZmf7_ybj*iW|JLsc>TUR7c-Q*^7#i|WGH1DgD((sWA3 z)+(Og#C|}L%d93*vo0*Zx@qfuxo^pB$}*pYV{R(d9t7evOS_1(sKXImZ&5n?LG}+! zLu=tkz<8jd=#uf4j#_}7v;fdpd^|p0o!X3p-RrZD{Nd~H@5*P8XlU=DQ zd>FI24{mv+x19z1IqYzs5Yh{di9#0Mk7h&UR-P(8Urs+~@%*gW0gO=h?9b`la%_p^ zUK|KhaPHqe?e@936g)ZItaBGL`G|I!|%lhg2Y z^~qjWv;F*L? zY_5hcj#T6vD^!v?DU;%N!>rpENAQqP77j^pQ(x0#jp6aS*44${9-pV_kb-{m>eXqm zZP^cGBfGC>6*ScPmNv}@Y5NK zcsbF}Rh;Ur>A5HFZv-0K2hHzEGCYfw+jXiJG+f7Oq)`Q=qCtl_8g8D}(=;{Iq^9g^ z`&coc(2vr6m^%B>T#9tkysuR2=e=vCr*!xaMmo?hgvP5Uw9%(zS%E5!2x$Gj95Od3^twd|9C`=$eN-!%QGH@1-v{L07-U zYQwFF2j9Q;Pd4(PXTJ?*f{pmpeD3;NmIviPLBHac_K!OS263~ZKk-x@Dd_)z$GyLM zi|6RA%m4KO!qMrE|2%}$+Mm@D48a`bdavFORO z9c#}% zpX(xv2PI47^XKE8ub~(YClcM1u?0FGtE%8{Q&PNYfR#}kQ1$U6eD)zPz3TL?!om8; zHTnK?M5?&Hk59gyA@fNDFW+F__}Nmm6zD_Nbr;K%7o@>svY-I#*86^gGH!W4{3^|q zR9k{wu(2L^3g(TCE};&9xB#ZK2KdNC0Gd9!gr>NdFHj z9aEOuU5qUb@YD10@%iGNdDYg|E}?MY4IBZ8Sx+d+n=%!_8O|ii)Y|f$tes$tf8|za?k;OJ7myq;ITe1IAokr z^OlV9@~mB4ETW2sf!CCwq11;D`^l>|>*3D$xH#Qo%F4r}^EM8|F%u?$DPN5b!uTem ziODP&s81kUxm4|tp?p5z;SOkeD#R$VI+zJs6~~m6zWv9af$Nm<2wenztxHdoluAe> zRk?)L>LVH(8)xQo%<+gcl$pu&e9l?48(NFNGRZJ&u}+XzRz2M5fbcS=n?EXLvClwK z1S`tXVJTU+>zJC_80q|=!{S(qB;Eho*}F^d3oMRf|Eri7tMj#+{IU$}CDr_9Ot_~Q zuYp4X{d#ic7hVQ<@TMv$>Gnl<>2p6Bs7ndSSv9^0g`5EA0BrzW;P#t6Dm8Dl#`=TX zp|%4t*n~-xlmGyDa%z-fy(Hr8u6a9VRDJl3WM?`n-C?r3*v|&y3gD^$?Buv;o~4`O zHAY0jX-SBllmcuMZbPBzwWZe}(_EAe=r%WZ zn?*^l`~k5}`KB(NHBn+ht7Q2of=K|xM;_BF#(p{4u~z}`Nl?V7|co`rJiQ-kxzyn;Ugj#{g3U@Ks5zw6`Bby4%nZ`YvBA6gxMkrEgzrh3TQW4acxF7!Zs z+1$u%UTe*%fxb~vTKj$BY?D%9O!_8TF%rnU}K`BpNz#?gELu7oft%25z6eWYFsuqV#RS6?=C z&%c$*MHeq`rZ*AmqWnkOg2~PYMg(lrYT(tN^r&dlqEIJD20Ts56^9YcI+sbd9wTLE zS9HAZ+}Y1emx+waGNOw;Dn%ed>SjAz2J#-2I`QeD#BrVGztJ5Qog9cInk{8zx3~+; z1j)E(3|{k#fum@szicXy6g9#A`Zcvx@sX?i@)q)d5YoTAB7;nS4|!cm`cS2L7Y9C@>jiYe+9hTb&S!=*#!J+JtEoup4) zo!>>;;q)Z3{Z};qm1n~kFK4nzTk|Ij=1VNbkxk&odaNO&eaH#|KPh$dX#%Z`{L1u< zkmNAB-k|K`rvR3xCEfN_Zwe~*H*&mPXU=hF`F8uX(5tZYBGHM#QI3V=svOCx1oB}a zZs1FScQrse=5+n9kP>1dL5dPhT=_}jK8XrP6d?*AttOgZ;rO!G32lX?f|Q!vg-2G1 z5YsdaC=^*DaIeYMA7(onlpwU$L?d#0)3le7?IH}7UlOB4tDr*=GnztGB17CZW2iC* z6^rt`iZ^fZjp(=T+Sq=!^kxBQA7N=NwLM8sL3Q@K48D>)EVT47iyOa-DakQc?Ki@+ z)Oq_|6xqrB3KYgWNnne#V;xqP#q@don+kM=Q^C_54mr192*nOQ=3#jOGGzxzUH0H{x!1;K zaB?bMqjcnq7F$W6KwRCUZDBUCX>mGajk$3;qk|X|D=!{u6#T7q|#`lx% zh9(2~xHdLynP$o9ui{j2^3rp);bFG_@Zma_!ehQ?A->2(MX@|UwXoo6P$)}9kC{HR zU}|FWboq7wY`zvB?cH|b4|61vfm2siJ)G#PohG~s`3OmwU&jJ`N*1rHdlAJajC68R zd)Id|G&EGp&X9&^YE^QZ{ZnXH03$w5%jdGnbU`b*#h13L+yE1TcLGYVX4)jrTC-mW&iE7Gi{ZEvL4b+P;`S0QG5 ziBneg=zQeQU&RaqlY}p{HZ{el?S%&eG0`_vsJ7)XY*p=w`|I;R{bqON?1wGw1_cco z=OxE|7+;l@yKo)o=3Dkoh)4DHjTc}Ir`X=fp7Zf>^XyBZoF_g`+L#!{leJvf(P6@z zrQw!~I4A4-k5egsV2&RKKJp9S_89xrhH7ApsO!(&ZX8fBR?5lA(ci=$Y_R?`i!~m#fXd!8EgT?P z>)&L()O~5(GiKzZgZF$~A=CJmX9kinBI<_oc{#G!<=ztVX~?eQ+cI1DTjHj!M8A-V z=@_8_q3Yl&i6+YKuelhsLgMAtrR5autgoQM2(I`TDX$st1e7Zrp zK|2*%_T99C?T*^o=@1o}`qRmvQ0@SB52P8aKhDZZ*tYq~Tfdl%AB4GsH89pziu~&R zX{I~Y5gs_mafMym5P}1n`CM2&BU`e2#jFvo$J*jX{e$dJWxuePLRi*$EIZArIF!we zzpQXutel8GO_h9y4r#@ys;dueI8(8-KEYSCL)>3rpDN~3C!XbZ*jL@fQ(RZgxmkP* zPc-`1ulciH-+aiiQ3#3TIKKj#QnkS>#lO^5N##Ime@PcQDD1n^NILkcgKT^3(|Gsd z`#azRG^eT4m8Mtmz*ezc&$80>)3r0N*~{;Xk!@GkRSI8lym4dP>yKinha^N#EZL9e zkuoUg=KkC;XVYKKwf5`5&}X9Ommc*n-Vcl0z0Xd`eS0oEJi=wu{X^RqPhuD`F$|3{ zRz?;2lyBNfC=xO@ui!eZ#aeYRYAQeTsH`c;F>DY`8Gy~6hnHu!(jMjQY?NED+;s4sH0|lw*s%J1?h#V`ino?c%H5t8y$^L9X%pz zA-ex3$2YzV&17-(<8}cPiT>)rx`UyGVLjSad-q0geZxU@;xZ!J+Mj!~;nes1rHouw zHouPdg4plNKfnDbc~DlCz3xf!`#*bMBq+-ibvh|2J$Do`-F{vEW#>qW%KBVTimF{tfyml)z1KhGRMvJ-*^OY5d`* z@-gX!PJCyEhL2?!WB2k*&DS^7e zj_pV-T%*C7)(tRyym6%a&~pdzh>g1mT%GIUm{Qq5%TW z-4iu$3LM=kAmCFzphtC=erm3<)BkIGyK%QXcP+$9dy8K-G6`(66>=2#Ag<=72NvwQ z$Y78CeqDd0d4=HRG6H+HbPN}8mMPvG%9IUKWWIo(Sv@ux^Dg_+XTX#>`MBv_f`8GdzE^}NE&#wgb*d`haKo0NN)5C@`89!}97I<7# zDO4$L(g(iZS9iAB#dSf+r!y^w#S^b>&=2!j7AChoPEeP1YVPsNbipn|6X|f=Jq9Bh zb>+9FlT*5-2zv?cY6?5J17N*MenR1NtPb8~YqSz3O6zzlO#H12xh?cm_{p&#XR&wM2T2XxQQn^J-j3UY_! z1-s>xTj^^4F+Nx|&J?Y<>X$d6<_i+LcI6Eh1SM?x!V7bOHD_aEo8G3W(%J-`*Sh=p zWAEWY!seFC-80GPpd2ou_m6+68Ew3`xDn}0Y}0}@K%gzrnQ$BP+F$m_+nUy+ouG; zNEjJj9>HsK^XA1vhrFtDzc?vw;9PdqTQDo}YS?;4TU&ef_IZIf4n~uDI-zVAmK9cD z=T1RMfpbx1ga9YUBUyFR0HYyx*PDtZ?yeaHLVtfplrWRCv~C^DPakn%h1SP5&e|dM zjg7vy&Bu<5%8)bZWNFhS_ry?CydRPFV%l5HuKbqA7$uy&kyXx^4Mjh+b zST{FF6#lZsGF>or3*W7VIo)|R`5ox=j2Yc4ne7WvK5+%X69bRj(Ipwfx)n8G*NW`y zR`V+!_c*GAe*dncYCOS`$!=hn7w+GCeCq8B;xU@`etvH{TmC&oVOIF3xPF3RMs2QB z=hKqzhMT?d{9{u7WA(}gHI3ONaSwvG7tS1V50F7#j>(>wkYm#Jb-9ZM#M;@}`Ost_ z+&XR{k>bWi^ z)-WJupNG)&JcaYLXt2R$!fR8B+u#HL$@9CWhZ5>ZS;<_H4$jeTh^CgyCPLdOSp;yk6R1n*LqQySW>R7o& za*%@jVq93{;9W7yGv@f($X=`Z#wVJo8**|$!89Wzvew#SXj7(wp^Q= z{6|F=Vax=5hfs6+rRCdry*pIHb&0~^4YjqC>hUO}$JusGU#DXl8ViF;7~1iSDKGOa z$Em2mrt(-90jyZ!Hh+OP?jiaGRR`68jYFnIk9KsAL`6=OhP$U$g-3)f(2SbulH+tQ zHpR{@&%3|v9UP~hO7N5ZqP7M7-TssjDvO`T&f2<~ubZO@%W7C}ujO2D#*&5`aItpU zjjdyHZm~JC(CJV#*>>XSTr_hLA?r%U6zG&pC&<>a=#-_5&fj_3)CK-Xv0kHjCiHXc zIm))#W>;Hc8DngkbxE$b-nGoYo<|SkU9A5nd34ueOS8_LQ_b_``o>Hd`vWG!+8Md_ zpG><(=0}*p)~YJ^>~W-#!{)9|6I!z2 z=-M)u*)YZGXzHS%3#nQMH>B$ZdX?zLHVNFaP7w$9t(RKxd6LM5%$fcJS6%FCOF0Z` zoyKy%#w;rPKF@fpRs}JL@q(ZlG_!SDs!MuSJ7G=L)29)b8R>&EPar|pXDBj$-USzzF4J^5$A{ZZATkfh?x7D*0>IalSiJV{7ePe;9{GIoa>90=k_Cp}0E*+nF;P+J7$k5e2myuC z-0hgi*_d@@lnhkF(2#9SP2&^Dd3T{CZ*#A z>tHJU|1x$V;YOVxeQ87_<=L}mwaE7IQXPs=l z3sCXh_3t6-OV7K4}0>Y zmqp0YM#n5u!^vcW_I=Ss0U73nL!|S)L`g*jg-b~1IBu5$YiF2P-_!(soqRy)JprXR zyIw3wX=%A&Kdb;u+x?JJ4_$$`e{~QVW5dAH*am_vOmH)!(7gF|0=g9|K7(QY7@^bCDAtDDPgYe_Gz1uGrR75# zqP-yy>Y(M*%Ee{LRg^DZ`hdQd#F9Zz0+D&YMpFQSTzXqkXh(Go0}maS)n*geG{f`E z87QwQtj3SjovH~A6d5}Aw93ofK^QUmcW(ob1;3?^bU0WDW!M9aDQ*);AJ4yOQ_;Gno51x zKSQfDZ}r8<=hsG;=9=RVA11s3lXhO-`9X-=`TIIe!V$iC`d7K$FZ*B3|j{*N#UT#lL+0 z@?`}3mF#s|P)Nwdf4^|?FaF;?S!M*~l1Rqf9$2G!a8;c=TpN%}wz z8Cco{Mokdvd?xMQK2<3x1$kqsZ2nCOrdyLn)l#_qTKnNce!Tb|}P{6krRKsKS7!(QoMJ$AlBgJ>= zS8}{fg#27vmQY&Pl@^hD6Fl@1p#7mp@|NgKtr7mi*-6bBf=nJ+q}6n|*xB97xl20y zkqXQ^HLEPLsxU4(N)*z0lWT5@;5xS?m7HA8Ej&ernbu;B4NSUyd(G+u%`WSN*JhO9 zD54`HbN@f;-UF_wyxSVa#}Q@*k(pselsY!Bg3>#qj-pgW5a||ri`39UtRNzwA|TxY zA~i~HAu1q*svwEfP(=t3S_mX0dG`V5`JUp;z3=zk``!2T_ZtRMPR=>`m$lbgdvB3b zio>+>a`PON2qJUj5@oBFt`*;W1G1w?g^QaTh&|VbQAvtjcL?+<7edMAe%^WtGqe-r zQc#;Ot8o@xLD_kL_bf;dD%Ipf^(2>N(M#56!%4s(UX?sxs{1KQPe&7^r9csZ4!uf> z0T)7Sy1QvBh=4c=-1;4YuLh3Y{e4%hJ8}-z)+uuG^26HkyFq}x?mYx2Q$joA`fwjq zN)7z2vlA2*7RA9Z*vaWcXeg*YAm1Xw4>0+Ly;ZNfpkx7j30tu}sfcLf=bP5u9Nr^= zh$dAhCezY5NryIwq>aw`--%zO@^&F#QN#RaM7GFmT-Z*{&)Q^sG7bJO2^ zlXtn;sE0j4lgB%hTss)rUxfE#Cw6{!JcG9AYn^XNGZPN6cWb` zJ}WYhiCv^Vk)poYiK$XznKA?49W#MraBR@;QOPcNXx5!**5$X4N0zJPbezT_a54{N%RzVm=Zj5h@VhG!iuVf!>Ek0!&ur! z32bF99fTJ(4ypWamJyc(%!;*O?oBQA7#KLLOnIzK1seAH5oACa{8USyo$xJGMY}_N zQ&mslVH6o8Lzby$l29~=NaiuVeA_J_n_X9({YHET^`VmC>WGBX&IuV-l5SmM*~9N( zHh{seGEDm>kEq6X0lR`f78kcU&bL2e16f@qP5BU}!Jdpb3cne|IV4Ly`zuf15Ne4dk{qdoKojI7oC zT0mZF0LP8SEHLml7Sb@wC!y_@VL-m(&XsOHn|(MeTQ-2!n-0wgN6(~%488qvdnBLe zXdf*ky3}+@I&J9&#e*M!?w=ZaUVhKR?cuU1x_Wv!&?AEqi(Lxi`ZdYhUwefM>XU6; z>hxUh9pk*>c8i9L3e0^{CLD{B)RwVjF>RNo0D>d(=GTeDUtrV2F#2S<6IiwxL>%^T zYqWMj@#a$B5?Dw^(UNRUl=2{VC5@|#(5I`2{x1)QWTvgKC|p5AE#lG%Y%|0Ujka{8 zx}z{jEA6uE0(~Q+CaC5A5P^5Lh4xQ3TyafP8Przhd6m=D+L!{#Tct8aftuWd|K6j4 zXWE3CP}o(3ck7`)mmZ?yxVr*aJ^eCGO-*xrX{=nUD*yKY*r7|~$UQD~$NhWvwqaSN z*d5v&Vl*4q_4{8%0uY0V^s-;Ew0DF20pMuAE%zPdC}O?5(~!7yg874 zhNmVg6QSQrz{Ggw2>2nd?%&_cn)>TQP$AT^vf7__AkWy+QX0x(_K&#)2V2S5RPAQ7 zGe>{#`K~;WJ`sr>l=!O=FS?y4SQ_!;ecb(5S_uSWU+oY3MK*)H+oxY=WY>h(KmX>S zK<9rf6@RQo3hDN9>+5liw(L%B&{O(i)v!20rmoJC-4-dG7TnI=!gKfQO2(R7&n=$U z4=J1l-J38gvHf74J|y3gR6T#5@cFh zT97nPs4`wqRAe_br>G$I;Mz*?_~*&HH*anLGt_#%iHsL7P8p_7&h;<7iDR=C_1?!F zAhGg*!MT6mShch7z?qIS5F9`qqI*+4P3fhjI?(w6!m{j~oD9eS1#T&3cGeRPm7Q%3 znL>s*3KUv|KW~fwQsBz)fKJrE$=FsyXk%()Bh%R#G?MZ9aJfqGdoU5Q7N-i(s7QK< zo~~|szDJr>+2zBnX+|q4jy0q_OM6N+TTagFB)AyIN_C3un>Ke5xL2&oz04uKdl-&a zR(@(iRK(8B%>*ing5_~Rz;ly?+XG_wcjHr3h?p9I>wXIkM0xdneSOmK0_Z}UUY^wz zNC3&Qy}frke*}N~`}fU|AR-Ja@^1{OL=Ws+tMMYza3WLBvLn1VF z2d_{T;y;2CPu`9hkYBtOplSv|HpcO_2)Di67)Y~1o0wvfD<{h$d zs27L@FD)_oL2AFms?knWUA>1&_Cu_Gp3d*7yCW?%xK!hlw&U;{iug?g4vAb_tvfi^c!fRegogO0X=b_L8QUzp}pt5p-I)~A7LzY*wxwD(|FZ`qPPsDAn&I+ z5~QeJ0G8F5dEd#Ozf#HWtKwA(4h(0*z)CLb73-6|omxMp=U zjR9W)KExe4Sy@|&d%$r5-vNXK7`ppaYWxfqT`)C=fyG(;)b8d9h#71%t>H-)xM)VF znsk1fCD~x~KNS~O2`K)54~xmZr6skV;KgD#-_b`US5;L_B7r~@;?e>zj#!~qQ#1dD zAgR@dAYgV~HM!y~>M6I^yi=C}m?bI*!fVbZ2}vVXjDZ{3d0lfv<*ECm#=p_#g^tS4 zHK6(I?E>AeTMH~q?3@|X=IYp-Gn3xgzwk_%X=^6(lTuN`^Nvg-iqpxZxp+cwN9$|X z?|2Bak*NEOsxi&}kt{>6O^~LDLg7{hB|{9sK=?`05eg4Tj}+rDdFWiuk2&?!^uFO} ztj72(&t>V`-aO}|>!5}6gun@U=#!S4Fh42H`l!RWmV?drYIJtW@6%&41M_%Id=Hs} zFMxhEtp9v#o9qj%ty?60h#&`>;TA29nd0@gK= ziuRL^)|Z1;mJYe{qkP-Tv$DEaOSR#{B#)Kx$bq=Hv$Wxo5+Tr~{D=3w{~S4RG;C9l zVi6wSVliXtIy+dS)B9oMDLR1dN4=Q5M;!ud986(wFa|0K4~w~I?(9-keVIj6ORxr2 zB=9#=VSm@pjedy3(J~?e$R%t2v&I&CYifkyW+|S}Z>pgA*K*qqu(0K!0xwpQsBJGe`zv#a^-*}Isn92_EzXF=Owp&a zw|5BRRw(8ALq#$DF2wc9*NUZF2`MS!q|N>LTd-(f;=-Le3z`#H8ojLEQl3eHiO*gA z5p!;-XZmmE;8;9^SP^-7d65MQ;?lpN;IW=zyTXTV4i4QezjfXrjD2#*t9HJTGmCy9 zMVJ}lCvE;7JbkX8vtJ_q<4sWna9YZ9Nb}k3ZVP`=F)Z4rsHwL%nOnR&If}qe%tifR zk4km}uVFL+;tvl9xURVLBEI6a7#x|tE#a(TTKIQ4>pCIOH88Mwm=<5MsE$R9(v^uq z%EKt}HdjK)5hV!I`CPrsfTV10UTWL*Jp{M|tA52P)(4SKfT9W|PWeLcKEWwX5F#r& zA3eJGmI&cE1op55DN1|v54Z)pHLqTldmCa+tgJ9XY>=y`W|NM({H(ZtF4-SWk9tVj z!+Qn)1hFjla)5eTI1OKb^*w)VDQ^6T|*Zh8L!PUtWAif01C+cmL4*?4-46k3wOBzrwWGG4H*71 z#`B_`(o^xU@zagx?Mcndw3ZDgb%h^{U#bqIXHy)mFXqIsl#bTvN)Pznd)#7;ejuYu z4i~|jySl!ABFF9ysZtAgBy?5@C9@AR&CEZZ&+ME(PyKWc0`~SKY{l+Zq`&=~5q+A~ znWk)s*eK1g+8@6U5(x~UGZONS<%afu(^)57viYS5bN-p~JGTvIHww(Trj@WdDUN|H zZW}Ia#5)H~F%@%!?&wmcs1;VF{WzJG#*YuS#l*cSI{rk;(b9TnF07DKXQZ2~KiFy3 zD+#OZ$s-0nZTBNE(GP&8aV_9XzYo8V#IDFCcH^c9+lJ0kV@ccDN$n0$q7(S_X3$j8=DdeE(hr3Bj^o9|s{`DY_)^Amk4(K%x-2K8M`$skb&P z;XTu4b?$~59>x9k1|;!YZZ~ZNKGh2O+{wMv4N*8UIG+>qnUSkudOTbX@X&}K_YOe_ zYM+VSV9zo(n_F1;7*mH8r1O1u@7N)K)_X|iEp&_lHpmdl0U&WX&@4b&T}*akLW>I5 z_KjfIokjo6d+59Tw?r9odO5J!W3|N;GZq^KjBnnWAJsJLEpgTKvwuzpML9qqSxuzH zJR(eOi528p2daO1`0JEl$jtfxMkQAem1)~I$hfB?Vg51+#~UCZ^ttmD>Rn-*R?wNx zN_{*t=<0c8FGy}`m~Ydj(FTH`-RLr8lMb@4U|2nrxG+i1U2L$WWhI#dyO4oI(BxAa z%bBI~>O8%~Aqj}{{_WpT=&EJw7^ILz8lNrmV%T1?b2&ZD?vUZmcRqZD>%ucY(bEf< zdCiCVzLz(l?}{1!g2*`U70PUsZT!$sp7PCGHS6qaG(ypMs`cDcN%qX~eLS^-e+A0% zJeO6PSq(cR@4vbh1apH5sB=};@q!s^8*sf;{;~?sN{uAvL}FrM zRo=9sf(8V&kOsET?*Q)=mA?ukHZMOdrN!C0%Fx?RzN7fvX$<3fyyI&8BGf7lRsu*9 zm8~jy^5lRGkH4Sa*-c^}vBM3qR^MFl`c2}_^;y@Kk{tuI{k1Lmx7YGJNG2El7~7? zK(V7W<>c@qr5VtJcBNcn;oVX2xccfKo%&8$2W8>xZb1T~f1mj?y0yS16` z1@X9q`YoGyL{Y)RNl4|`0%*FOm7l-px8baj#_UNb^dyZE+rWdC`MOpQ@hCqu=yL&H zeE4bco@U}*M!%U1%6x?Y36J7Tu3KNr8DY{6px0Il2l&a zfsjhMZKLNOnX!Tw-s|9_6(5NOPXaYmEASUX?y^tLRE6lqDXE@f@(r(=`{Fsq;;18d zohY$;@SWly4U@tz&RAIRx7GIfd>5om|EWQ7Oj^)%8pu8E`~s!L#l+_3JLsyCERzeD zc*;7yF2xG3JFoLCj}D^+#mUfE8n`~UGT+ujSQ6&;qLiUS27YEr$SBx2HF)kM!P@ye z<4hW}2x`tQyYDsSITwwcd`XFi9#i$GGp|q4pcE9fKfju(8JV^+B5m`6@iO9 z#383&mtaOd3x=klvwtxTS67@W&++M+;$p?Q$-=fqD5%{Y_@pA~HhT0y1sr zz)IZqb!*_7ZU18wpJZ@~RoTIvP&6#0C*@g1VDy7n_jq9&{|lXAanqh6Hg4{kBbb$D zdF|q#{E7$jE2rAT)Uucq26rpq0zY*e|9>YYy*eXp0APwE4Mn5Ot_s ze$*dS!^2JhBREhI-2@cjXZNm)cw4rnwXyNu%!*2Tmu{OG8?3B5#YPH`*F?-iss;b)F`@s!QfrdQJ z&K~z})@>Mv{>IR{W!W}jyS#h{6}{YD0BwxHGhlCL=g)S@>k!H0mLB8!_5G8}fJ(U< z0GUWg3T6uEW!l;_;19B;$dH%d=^0K>v^sNUM@B{u;-&+CIh;xYl2`+xoSFyJ_ghU% z)DBv_1OGr<1u`HbBLlcjN4%749yvtTS|slE>%+j7oIzwrGesy#-C}G@IgeNqv=vqN zXyRSdY){mIiXXr;_5TZ%(r=ZIBty2kcwM;nflRi<5%WPXnni<+M?n!u5qm~zYiT

OWI<%3;MalffSeG2A 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
wN~xXOJmR9%@Aby4GD)*fI{^{(hH=!69erw6Q^ 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

1k=lm#YLnGEJrL4V|^#If*cNcq| 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

V6sYJEdey@fN!_ii;9) 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

V_84%f2fDvyx@0*8~P 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 P{~bMY;&76j{*C_uz;1&L literal 0 HcmV?d00001 diff --git a/repo-images/donation-goal.svg b/repo-images/donation-goal.svg new file mode 100644 index 00000000..e63bc49f --- /dev/null +++ b/repo-images/donation-goal.svg @@ -0,0 +1,8 @@ + + Monthly donation goal + + Monthly Donation Goal ... / $750 + + + Loading... + diff --git a/scripts/crowdin-pretranslate.cjs b/scripts/crowdin-pretranslate.cjs index 822f5adf..32ffcb20 100644 --- a/scripts/crowdin-pretranslate.cjs +++ b/scripts/crowdin-pretranslate.cjs @@ -63,7 +63,7 @@ async function resolveFileId(projectId) { } async function pollPreTranslation(projectId, preTranslationId) { - for (let i = 0; i < 120; i++) { + for (;;) { const { data } = await request( "GET", `/projects/${projectId}/pre-translations/${preTranslationId}`, @@ -76,7 +76,6 @@ async function pollPreTranslation(projectId, preTranslationId) { } await new Promise((r) => setTimeout(r, 5000)); } - throw new Error("pre-translation timed out after 10 minutes"); } async function main() { diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs index f3889153..e1862a96 100644 --- a/scripts/patch-guacamole-lite.cjs +++ b/scripts/patch-guacamole-lite.cjs @@ -1,7 +1,7 @@ const fs = require("fs"); const path = require("path"); -const filePath = path.join( +const guacdClientPath = path.join( __dirname, "..", "node_modules", @@ -9,13 +9,22 @@ const filePath = path.join( "lib", "GuacdClient.js", ); +const cryptPath = path.join( + __dirname, + "..", + "node_modules", + "guacamole-lite", + "lib", + "Crypt.js", +); -if (!fs.existsSync(filePath)) { +if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) { console.log("[patch-guacamole-lite] File not found, skipping"); process.exit(0); } -let content = fs.readFileSync(filePath, "utf8"); +let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8"); +let cryptContent = fs.readFileSync(cryptPath, "utf8"); // Patch 1: version acceptance list const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {"; @@ -41,36 +50,185 @@ const newConnect = "\n" + " this.sendInstruction(['connect'].concat(connectArgs));"; +// Patch 4: answer guacd's dynamic argument requests locally. +// macOS Screen Sharing can request VNC username/password through the +// post-handshake `required`/`require` flow. guacamole-lite forwards those +// instructions to the browser, but Termix already keeps the credentials in the +// server-side token and the browser does not provide an onrequired handler. +const oldSendBuffer = + " this.lastActivity = Date.now();\n" + " this.sendBuffer = '';"; +const newSendBuffer = + " this.lastActivity = Date.now();\n" + + " this.sendBuffer = '';\n" + + " this.nextArgumentStreamIndex = 0;"; + +const oldSendInstructionBlock = + " sendInstruction(instruction) {\n" + + " // convert every element in the instruction array to a string. convert null to an empty string\n" + + " instruction = instruction.map((element) => {\n" + + " if (element === null || element === undefined) {\n" + + " return '';\n" + + " }\n" + + " return String(element);\n" + + " });\n" + + "\n" + + " const instructionString = GuacamoleParser.toInstruction(instruction);\n" + + " this.send(instructionString);\n" + + " }\n"; +const newSendInstructionBlock = + oldSendInstructionBlock + + "\n" + + " sendArgumentValue(name, value) {\n" + + " const stream = this.nextArgumentStreamIndex++;\n" + + " this.sendInstruction(['argv', stream, 'text/plain', name]);\n" + + " this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" + + " this.sendInstruction(['end', stream]);\n" + + " }\n" + + "\n" + + " sendRequiredArguments(params) {\n" + + " params.forEach((name) => {\n" + + " this.sendArgumentValue(name, this.connectionSettings[name]);\n" + + " });\n" + + " }\n"; + +const oldReadyHandler = + ' // Handle "ready" instruction\n' + + " if (opcode === 'ready') {"; +const newReadyHandler = + " // Handle dynamic argument requests from guacd\n" + + " if (opcode === 'required' || opcode === 'require') {\n" + + " this.sendRequiredArguments(params);\n" + + " return;\n" + + " }\n" + + "\n" + + oldReadyHandler; + let patched = false; -if (!content.includes(newVersionCheck)) { - if (!content.includes(oldVersionCheck)) { +if (!guacdClientContent.includes(newVersionCheck)) { + if (!guacdClientContent.includes(oldVersionCheck)) { console.log( "[patch-guacamole-lite] Version check target not found, skipping", ); process.exit(0); } - content = content.replace(oldVersionCheck, newVersionCheck); + guacdClientContent = guacdClientContent.replace( + oldVersionCheck, + newVersionCheck, + ); patched = true; } -if (!content.includes(newTimezone)) { - if (!content.includes(oldTimezone)) { +if (!guacdClientContent.includes(newTimezone)) { + if (!guacdClientContent.includes(oldTimezone)) { console.log("[patch-guacamole-lite] Timezone target not found, skipping"); process.exit(0); } - content = content.replace(oldTimezone, newTimezone); + guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone); patched = true; } -if (!content.includes(newConnect)) { - if (!content.includes(oldConnect)) { +if (!guacdClientContent.includes(newConnect)) { + if (!guacdClientContent.includes(oldConnect)) { console.log( "[patch-guacamole-lite] Connect target not found, skipping name patch", ); process.exit(0); } - content = content.replace(oldConnect, newConnect); + guacdClientContent = guacdClientContent.replace(oldConnect, newConnect); + patched = true; +} + +if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) { + if (!guacdClientContent.includes(oldSendBuffer)) { + console.log( + "[patch-guacamole-lite] Argument stream index target not found, skipping", + ); + process.exit(0); + } + guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer); + patched = true; +} + +if (!guacdClientContent.includes("sendRequiredArguments(params) {")) { + if (!guacdClientContent.includes(oldSendInstructionBlock)) { + console.log( + "[patch-guacamole-lite] Required argument helper target not found, skipping", + ); + process.exit(0); + } + guacdClientContent = guacdClientContent.replace( + oldSendInstructionBlock, + newSendInstructionBlock, + ); + patched = true; +} + +if ( + !guacdClientContent.includes("opcode === 'required' || opcode === 'require'") +) { + if (!guacdClientContent.includes(oldReadyHandler)) { + console.log( + "[patch-guacamole-lite] Required opcode target not found, skipping", + ); + process.exit(0); + } + guacdClientContent = guacdClientContent.replace( + oldReadyHandler, + newReadyHandler, + ); + patched = true; +} + +// Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings, +// which corrupts IV/ciphertext bytes and non-ASCII connection settings such as +// RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and +// decode the plaintext JSON as UTF-8. +const oldDecryptBlock = + " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" + + "\n" + + " encoded.iv = this.constructor.base64decode(encoded.iv);\n" + + " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" + + "\n" + + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" + + "\n" + + " let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" + + " decrypted += decipher.final('ascii');"; +const oldPartiallyPatchedDecryptBlock = + " let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" + + "\n" + + " encoded.iv = this.constructor.base64decode(encoded.iv);\n" + + " encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" + + "\n" + + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" + + "\n" + + " let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" + + " decrypted += decipher.final('utf8');"; +const newDecryptBlock = + " const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" + + "\n" + + " const iv = Buffer.from(encoded.iv, 'base64');\n" + + " const value = Buffer.from(encoded.value, 'base64');\n" + + "\n" + + " const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" + + "\n" + + " let decrypted = decipher.update(value, undefined, 'utf8');\n" + + " decrypted += decipher.final('utf8');"; + +if (!cryptContent.includes(newDecryptBlock)) { + if (cryptContent.includes(oldDecryptBlock)) { + cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock); + } else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) { + cryptContent = cryptContent.replace( + oldPartiallyPatchedDecryptBlock, + newDecryptBlock, + ); + } else { + console.log( + "[patch-guacamole-lite] UTF-8 token decrypt target not found, skipping", + ); + process.exit(0); + } patched = true; } @@ -79,7 +237,8 @@ if (!patched) { process.exit(0); } -fs.writeFileSync(filePath, content); +fs.writeFileSync(guacdClientPath, guacdClientContent); +fs.writeFileSync(cryptPath, cryptContent); console.log( - "[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with name handshake instruction", + "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt", ); diff --git a/scripts/patch-guacamole-lite.test.ts b/scripts/patch-guacamole-lite.test.ts new file mode 100644 index 00000000..755d7174 --- /dev/null +++ b/scripts/patch-guacamole-lite.test.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("patch-guacamole-lite", () => { + it("handles guacd dynamic argument requests", () => { + const guacdClientPath = path.join( + process.cwd(), + "node_modules", + "guacamole-lite", + "lib", + "GuacdClient.js", + ); + + const content = fs.readFileSync(guacdClientPath, "utf8"); + + expect(content).toContain("sendRequiredArguments(params)"); + expect(content).toContain("opcode === 'required' || opcode === 'require'"); + expect(content).toContain("this.sendInstruction(['argv'"); + expect(content).toContain("this.sendInstruction(['blob'"); + expect(content).toContain("this.sendInstruction(['end'"); + }); +}); diff --git a/scripts/publish-youtube.cjs b/scripts/publish-youtube.cjs index 22ab28fa..a9836857 100644 --- a/scripts/publish-youtube.cjs +++ b/scripts/publish-youtube.cjs @@ -50,7 +50,8 @@ async function getVideoStatus(accessToken, videoId) { `https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`, { headers: { Authorization: `Bearer ${accessToken}` } }, ); - if (!res.ok) throw new Error(`videos.list failed (${res.status}): ${await res.text()}`); + if (!res.ok) + throw new Error(`videos.list failed (${res.status}): ${await res.text()}`); const json = await res.json(); return json.items?.[0]?.status?.privacyStatus ?? null; } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 2d93745c..c2e0997d 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -17,8 +17,11 @@ import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; import userPreferencesRoutes from "./routes/user-preferences.js"; import proxmoxRoutes from "./routes/proxmox.js"; +import termixIdRoutes from "./routes/termix-id.js"; import { registerAuditLogRoutes } from "./routes/audit-log-routes.js"; import { registerTailscaleRoutes } from "./routes/tailscale-routes.js"; +import vaultRoutes from "./routes/vault.js"; +import alertRulesRoutes from "./routes/alert-rules-routes.js"; import { createCorsMiddleware } from "../utils/cors-config.js"; import fs from "fs"; import path from "path"; @@ -1788,8 +1791,11 @@ app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); app.use("/user-preferences", userPreferencesRoutes); app.use("/proxmox", proxmoxRoutes); +app.use("/termix-id", termixIdRoutes); registerAuditLogRoutes(app, authenticateJWT); registerTailscaleRoutes(app, authenticateJWT); +app.use("/vault", vaultRoutes); +app.use("/", alertRulesRoutes); const frontendDistPaths = [ path.join(__dirname, "../../../dist"), diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 961062f0..22247c71 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -8,6 +8,7 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js" import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseMigration } from "../../utils/database-migration.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import { getDefaultGuacdUrl } from "../../utils/guacd-config.js"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); @@ -196,6 +197,22 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + transports TEXT, + user_verification TEXT NOT NULL DEFAULT 'preferred', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -636,12 +653,11 @@ async function initializeCompleteDatabase(): Promise { .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get(); if (!row) { - const defaultGuacUrl = `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('guac_url', ?)", ) - .run(defaultGuacUrl); + .run(getDefaultGuacdUrl()); } } catch (e) { databaseLogger.warn("Could not initialize guac_url setting", { @@ -689,12 +705,29 @@ const migrateSchema = () => { addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER"); addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER"); addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER"); + addColumnIfNotExists( + "user_preferences", + "expand_app_rail_on_hover", + "INTEGER", + ); addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER"); addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER"); addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER"); addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER"); addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT"); addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); + addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT"); + + sqlite.exec(` + CREATE TABLE IF NOT EXISTS dashboard_service_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + url TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); @@ -714,6 +747,24 @@ const migrateSchema = () => { addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); + sqlite.exec(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + transports TEXT, + user_verification TEXT NOT NULL DEFAULT 'preferred', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "tags", "TEXT"); @@ -784,6 +835,11 @@ const migrateSchema = () => { "override_credential_username", "INTEGER", ); + addColumnIfNotExists( + "ssh_data", + "vault_profile_id", + "INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL", + ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); @@ -990,6 +1046,111 @@ const migrateSchema = () => { } } + try { + sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS termix_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + handle TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create termix_identities table", { + operation: "schema_migration", + error: createError, + }); + } + } + + // Enforce one-Termix-ID-per-user on databases where the table predates the + // UNIQUE(user_id) constraint above. + try { + sqlite.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)", + ); + } catch (indexError) { + databaseLogger.warn("Failed to create termix_identities user_id unique index", { + operation: "schema_migration", + error: indexError, + }); + } + + try { + sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS termix_identity_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + key_type TEXT NOT NULL, + algorithm TEXT NOT NULL, + label TEXT, + comment TEXT, + source TEXT NOT NULL DEFAULT 'manual', + credential_id INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create termix_identity_keys table", { + operation: "schema_migration", + error: createError, + }); + } + } + + // The public resolver fetches keys by identity_id on every request; index it. + try { + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)", + ); + } catch (indexError) { + databaseLogger.warn("Failed to create termix_identity_keys identity index", { + operation: "schema_migration", + error: indexError, + }); + } + + try { + sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS termix_identity_ca ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id INTEGER NOT NULL UNIQUE, + user_id TEXT NOT NULL, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + validity_days INTEGER NOT NULL DEFAULT 90, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create termix_identity_ca table", { + operation: "schema_migration", + error: createError, + }); + } + } + try { sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get(); } catch { @@ -1470,6 +1631,67 @@ const migrateSchema = () => { } } + try { + sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS vault_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + folder TEXT, + tags TEXT, + vault_addr TEXT NOT NULL, + vault_namespace TEXT, + oidc_mount TEXT, + oidc_role TEXT, + ssh_mount TEXT, + ssh_role TEXT NOT NULL, + valid_principals TEXT, + key_type TEXT, + shared INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create vault_profiles table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS vault_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + profile_id INTEGER NOT NULL, + ssh_cert TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + last_used TEXT, + UNIQUE(user_id, profile_id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create vault_tokens table", { + operation: "schema_migration", + error: createError, + }); + } + } + try { sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get(); } catch { @@ -1711,6 +1933,197 @@ const migrateSchema = () => { }); } + // --- metrics-history begin --- + try { + sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS host_metrics_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE, + ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + cpu_percent REAL, + mem_percent REAL, + disk_percent REAL, + net_rx_bytes INTEGER, + net_tx_bytes INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts + ON host_metrics_history (host_id, ts DESC); + `); + } catch (createError) { + databaseLogger.warn("Failed to create host_metrics_history table", { + operation: "schema_migration", + error: createError, + }); + } + } + // --- metrics-history end --- + + // --- alerts begin --- + try { + sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE, + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + trigger_type TEXT NOT NULL, + threshold_value REAL, + threshold_duration_seconds INTEGER, + cooldown_minutes INTEGER NOT NULL DEFAULT 15, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create alert_rules table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS notification_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + type TEXT NOT NULL, + config TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create notification_channels table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS alert_rule_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create alert_rule_channels table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS alert_firings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE, + host_id INTEGER NOT NULL, + host_name TEXT NOT NULL, + fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at TEXT, + value REAL, + message TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + acknowledged INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired + ON alert_firings (user_id, fired_at DESC); + `); + } catch (createError) { + databaseLogger.warn("Failed to create alert_firings table", { + operation: "schema_migration", + error: createError, + }); + } + } + // --- alerts end --- + + // Seed default metrics history retention setting + try { + const retentionRow = sqlite + .prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'") + .get(); + if (!retentionRow) { + sqlite + .prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')") + .run(); + } + } catch (e) { + databaseLogger.warn("Could not initialize metrics_history_retention_days setting", { + operation: "schema_migration", + error: e, + }); + } + + // --- homepage begin --- + try { + sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS homepage_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type_id TEXT NOT NULL, + title TEXT, + config TEXT NOT NULL DEFAULT '{}', + folder_id INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create homepage_items table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS homepage_layouts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + layout TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create homepage_layouts table", { + operation: "schema_migration", + error: createError, + }); + } + } + // --- homepage end --- + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 4e72535d..fabad70f 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; export const users = sqliteTable("users", { @@ -80,6 +80,25 @@ export const trustedDevices = sqliteTable("trusted_devices", { .default(sql`CURRENT_TIMESTAMP`), }); +export const webauthnCredentials = sqliteTable("webauthn_credentials", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + credentialId: text("credential_id").notNull(), + publicKey: text("public_key").notNull(), + counter: integer("counter").notNull().default(0), + deviceType: text("device_type"), + backedUp: integer("backed_up", { mode: "boolean" }).notNull().default(false), + transports: text("transports"), + userVerification: text("user_verification").notNull().default("preferred"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + lastUsedAt: text("last_used_at"), +}); + export const hosts = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") @@ -111,6 +130,13 @@ export const hosts = sqliteTable("ssh_data", { overrideCredentialUsername: integer("override_credential_username", { mode: "boolean", }), + // When authType is "vault", the host authenticates via a Vault SSH signer + // profile (shared settings, no secrets). The signing certificate is obtained + // per-user at connect time via an interactive Vault OIDC flow. + vaultProfileId: integer("vault_profile_id").references( + () => vaultProfiles.id, + { onDelete: "set null" }, + ), enableTerminal: integer("enable_terminal", { mode: "boolean" }) .notNull() .default(true), @@ -661,6 +687,63 @@ export const opksshTokens = sqliteTable("opkssh_tokens", { lastUsed: text("last_used"), }); +// Vault SSH signer profiles. These hold ONLY non-secret connection settings and +// are intended to be shared across users (shared === true makes a profile +// visible to every user on the server). Each user authenticates to Vault via an +// interactive OIDC flow at connect time; no tokens or keys are stored here. +export const vaultProfiles = sqliteTable("vault_profiles", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + folder: text("folder"), + tags: text("tags"), + // Vault server connection (non-secret) + vaultAddr: text("vault_addr").notNull(), + vaultNamespace: text("vault_namespace"), + // OIDC auth method mount + role used to obtain a Vault token interactively + oidcMount: text("oidc_mount"), + oidcRole: text("oidc_role"), + // SSH secrets engine mount + signer role used to sign the ephemeral key + sshMount: text("ssh_mount"), + sshRole: text("ssh_role").notNull(), + validPrincipals: text("valid_principals"), + // Ephemeral keypair algorithm to generate per connection + keyType: text("key_type"), + // When true the profile is visible/usable by all users on the server + shared: integer("shared", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +// Per-user cache of the ephemeral SSH private key + Vault-signed certificate. +// Transient: rows live only until the certificate expires. Secret fields are +// encrypted under the user's data-encryption key (see field-crypto.ts). +export const vaultTokens = sqliteTable("vault_tokens", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + profileId: integer("profile_id") + .notNull() + .references(() => vaultProfiles.id, { onDelete: "cascade" }), + + sshCert: text("ssh_cert", { length: 8192 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + lastUsed: text("last_used"), +}); + export const apiKeys = sqliteTable("api_keys", { id: text("id").primaryKey(), userId: text("user_id") @@ -710,6 +793,9 @@ export const userPreferences = sqliteTable("user_preferences", { showHostTags: integer("show_host_tags", { mode: "boolean" }), hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }), pinAppRail: integer("pin_app_rail", { mode: "boolean" }), + expandAppRailOnHover: integer("expand_app_rail_on_hover", { + mode: "boolean", + }), foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }), confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }), disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }), @@ -788,6 +874,79 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", { .default(sql`CURRENT_TIMESTAMP`), }); +// --- termix-id begin --- +// A user claims a unique public handle. Their published SSH public keys are +// served at an unauthenticated resolver endpoint in authorized_keys format, +// so any server can be provisioned with `curl /termix-id/u/ >> ~/.ssh/authorized_keys`. +export const termixIdentities = sqliteTable("termix_identities", { + id: integer("id").primaryKey({ autoIncrement: true }), + // One Termix ID per user — enforced in schema, not just in code. + userId: text("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + handle: text("handle").notNull().unique(), + description: text("description"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const termixIdentityKeys = sqliteTable("termix_identity_keys", { + id: integer("id").primaryKey({ autoIncrement: true }), + identityId: integer("identity_id") + .notNull() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // Public keys are non-secret, so they are stored in plaintext (no field-level + // encryption). This is what lets the unauthenticated resolver serve them. + publicKey: text("public_key", { length: 8192 }).notNull(), + // Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for + // the / resolver filter (RSA / ED25519 / ECDSA / ...). + keyType: text("key_type").notNull(), + algorithm: text("algorithm").notNull(), + label: text("label"), + comment: text("comment"), + // "manual" (pasted) or "credential" (imported from an ssh_credentials entry). + source: text("source").notNull().default("manual"), + credentialId: integer("credential_id").references(() => sshCredentials.id, { + onDelete: "set null", + }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// Per-identity certificate authority. Servers that trust this CA (via +// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs, +// giving central revocation (rotate the CA) and expiry (cert validity). +export const termixIdentityCa = sqliteTable("termix_identity_ca", { + id: integer("id").primaryKey({ autoIncrement: true }), + identityId: integer("identity_id") + .notNull() + .unique() + .references(() => termixIdentities.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // CA public key (plaintext — it is published); CA private key is field-encrypted. + publicKey: text("public_key", { length: 4096 }).notNull(), + privateKey: text("private_key", { length: 8192 }).notNull(), + validityDays: integer("validity_days").notNull().default(90), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- termix-id end --- + // --- tmux-monitor begin --- export const tmuxSessionTags = sqliteTable("tmux_session_tags", { id: integer("id").primaryKey({ autoIncrement: true }), @@ -804,3 +963,118 @@ export const tmuxSessionTags = sqliteTable("tmux_session_tags", { .default(sql`CURRENT_TIMESTAMP`), }); // --- tmux-monitor end --- + +// --- metrics-history begin --- +export const hostMetricsHistory = sqliteTable("host_metrics_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ts: text("ts") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + cpuPercent: real("cpu_percent"), + memPercent: real("mem_percent"), + diskPercent: real("disk_percent"), + netRxBytes: integer("net_rx_bytes"), + netTxBytes: integer("net_tx_bytes"), +}); +// --- metrics-history end --- + +// --- alerts begin --- +export const alertRules = sqliteTable("alert_rules", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }), + name: text("name").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + triggerType: text("trigger_type").notNull(), + thresholdValue: real("threshold_value"), + thresholdDurationSeconds: integer("threshold_duration_seconds"), + cooldownMinutes: integer("cooldown_minutes").notNull().default(15), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const notificationChannels = sqliteTable("notification_channels", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + type: text("type").notNull(), + config: text("config").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const alertRuleChannels = sqliteTable("alert_rule_channels", { + id: integer("id").primaryKey({ autoIncrement: true }), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + channelId: integer("channel_id") + .notNull() + .references(() => notificationChannels.id, { onDelete: "cascade" }), +}); + +export const alertFirings = sqliteTable("alert_firings", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + ruleId: integer("rule_id") + .notNull() + .references(() => alertRules.id, { onDelete: "cascade" }), + hostId: integer("host_id").notNull(), + hostName: text("host_name").notNull(), + firedAt: text("fired_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + resolvedAt: text("resolved_at"), + value: real("value"), + message: text("message").notNull(), + severity: text("severity").notNull().default("warning"), + acknowledged: integer("acknowledged", { mode: "boolean" }).notNull().default(false), +}); +// --- alerts end --- + +// --- homepage begin --- +export const homepageItems = sqliteTable("homepage_items", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + typeId: text("type_id").notNull(), + title: text("title"), + config: text("config").notNull().default("{}"), + folderId: integer("folder_id"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const homepageLayouts = sqliteTable("homepage_layouts", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + // JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number } + layout: text("layout").notNull().default("{}"), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); +// --- homepage end --- diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts index 0fcf286b..8d55f65e 100644 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ b/src/backend/database/routes/acme-ssl-routes.ts @@ -12,6 +12,10 @@ import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; const DATA_DIR = process.env.DATA_DIR || "./db/data"; const SSL_DIR = path.join(DATA_DIR, "ssl"); const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot"); +const CERTBOT_DIR = path.join(DATA_DIR, "certbot"); +const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config"); +const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work"); +const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs"); const CLOUDFLARE_CREDENTIALS_FILE = path.join( DATA_DIR, "ssl", @@ -270,6 +274,15 @@ export function registerAcmeSSLRoutes( await fs.mkdir(SSL_DIR, { recursive: true }); await fs.mkdir(ACME_WEBROOT, { recursive: true }); + await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true }); + await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true }); + await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true }); + + const certbotDirFlags = [ + `--config-dir "${CERTBOT_CONFIG_DIR}"`, + `--work-dir "${CERTBOT_WORK_DIR}"`, + `--logs-dir "${CERTBOT_LOGS_DIR}"`, + ].join(" "); let certbotCmd: string; @@ -304,6 +317,7 @@ export function registerAcmeSSLRoutes( `"${email}"`, "--cert-name", "termix", + certbotDirFlags, ].join(" "); } else { certbotCmd = [ @@ -320,6 +334,7 @@ export function registerAcmeSSLRoutes( `"${email}"`, "--cert-name", "termix", + certbotDirFlags, ].join(" "); } @@ -331,7 +346,7 @@ export function registerAcmeSSLRoutes( execSync(certbotCmd, { stdio: "pipe", timeout: 120000 }); - const liveDir = `/etc/letsencrypt/live/termix`; + const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix"); const fullchainSrc = path.join(liveDir, "fullchain.pem"); const privkeySrc = path.join(liveDir, "privkey.pem"); const certDest = path.join(SSL_DIR, "termix.crt"); diff --git a/src/backend/database/routes/alert-rules-routes.ts b/src/backend/database/routes/alert-rules-routes.ts new file mode 100644 index 00000000..10b8d7e0 --- /dev/null +++ b/src/backend/database/routes/alert-rules-routes.ts @@ -0,0 +1,660 @@ +import express, { type Request, type Response } from "express"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { getDb } from "../db/index.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { DatabaseSaveTrigger } from "../db/index.js"; +import { databaseLogger } from "../../utils/logger.js"; +import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +const VALID_TRIGGER_TYPES = new Set([ + "host_offline", + "host_online", + "cpu_threshold", + "memory_threshold", + "disk_threshold", + "health_check_failure", + "health_check_recovery", + "user_login", +]); + +router.use(authenticateJWT); + +// ---- Notification Channels ---- + +/** + * @openapi + * /notification-channels: + * get: + * summary: List notification channels for the current user + * tags: + * - Alerts + * responses: + * 200: + * description: List of notification channels. + */ +router.get("/notification-channels", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const rows = getDb() + .$client.prepare( + "SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC", + ) + .all(userId); + res.json(rows); + } catch (err) { + databaseLogger.error("Failed to list notification channels", { + operation: "list_channels", + error: err, + }); + res.status(500).json({ error: "Failed to list channels" }); + } +}); + +/** + * @openapi + * /notification-channels: + * post: + * summary: Create a notification channel + * tags: + * - Alerts + */ +router.post("/notification-channels", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, type, config, enabled } = req.body as { + name: string; + type: string; + config: unknown; + enabled?: boolean; + }; + + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ error: "name is required" }); + } + if (type !== "webhook" && type !== "ntfy") { + return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" }); + } + if (!config || typeof config !== "object") { + return res.status(400).json({ error: "config is required" }); + } + if (type === "ntfy") { + const c = config as Record; + if (!c.url || typeof c.url !== "string") + return res.status(400).json({ error: "ntfy config requires url" }); + if (!c.topic || typeof c.topic !== "string") + return res.status(400).json({ error: "ntfy config requires topic" }); + } + if (type === "webhook") { + const c = config as Record; + if (!c.url || typeof c.url !== "string") + return res.status(400).json({ error: "webhook config requires url" }); + } + + try { + const result = getDb() + .$client.prepare( + `INSERT INTO notification_channels (user_id, name, type, config, enabled) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + userId, + name.trim(), + type, + JSON.stringify(config), + enabled !== false ? 1 : 0, + ); + const row = getDb() + .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") + .get(result.lastInsertRowid); + DatabaseSaveTrigger.triggerSave("notification_channel_created"); + res.status(201).json(row); + } catch (err) { + databaseLogger.error("Failed to create notification channel", { + operation: "create_channel", + error: err, + }); + res.status(500).json({ error: "Failed to create channel" }); + } +}); + +/** + * @openapi + * /notification-channels/{id}: + * put: + * summary: Update a notification channel + * tags: + * - Alerts + */ +router.put("/notification-channels/:id", (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const { name, type, config, enabled } = req.body as { + name?: string; + type?: string; + config?: unknown; + enabled?: boolean; + }; + + const existing = getDb() + .$client.prepare( + "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", + ) + .get(channelId, userId); + if (!existing) return res.status(404).json({ error: "Channel not found" }); + + if (type && type !== "webhook" && type !== "ntfy") { + return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" }); + } + + try { + const updates: string[] = []; + const params: unknown[] = []; + if (name !== undefined) { + updates.push("name = ?"); + params.push(name.trim()); + } + if (type !== undefined) { + updates.push("type = ?"); + params.push(type); + } + if (config !== undefined) { + updates.push("config = ?"); + params.push(JSON.stringify(config)); + } + if (enabled !== undefined) { + updates.push("enabled = ?"); + params.push(enabled ? 1 : 0); + } + + if (updates.length === 0) return res.json({ success: true }); + + params.push(channelId, userId); + getDb() + .$client.prepare( + `UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, + ) + .run(...params); + + const row = getDb() + .$client.prepare("SELECT * FROM notification_channels WHERE id = ?") + .get(channelId); + DatabaseSaveTrigger.triggerSave("notification_channel_updated"); + res.json(row); + } catch (err) { + databaseLogger.error("Failed to update notification channel", { + operation: "update_channel", + error: err, + }); + res.status(500).json({ error: "Failed to update channel" }); + } +}); + +/** + * @openapi + * /notification-channels/{id}: + * delete: + * summary: Delete a notification channel + * tags: + * - Alerts + */ +router.delete("/notification-channels/:id", (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const existing = getDb() + .$client.prepare( + "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", + ) + .get(channelId, userId); + if (!existing) return res.status(404).json({ error: "Channel not found" }); + getDb() + .$client.prepare("DELETE FROM notification_channels WHERE id = ?") + .run(channelId); + DatabaseSaveTrigger.triggerSave("notification_channel_deleted"); + res.json({ success: true }); +}); + +/** + * @openapi + * /notification-channels/{id}/test: + * post: + * summary: Send a test notification + * tags: + * - Alerts + */ +router.post( + "/notification-channels/:id/test", + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const channelId = Number(req.params.id); + const row = getDb() + .$client.prepare( + "SELECT * FROM notification_channels WHERE id = ? AND user_id = ?", + ) + .get(channelId, userId) as { type: string; config: string } | undefined; + if (!row) return res.status(404).json({ error: "Channel not found" }); + + const testPayload = { + hostName: "Test Host", + hostId: 0, + triggerType: "test", + message: "This is a test notification from Termix", + severity: "info" as const, + timestamp: new Date().toISOString(), + ruleId: 0, + ruleName: "Test", + }; + + try { + let config: Record; + try { + config = JSON.parse(row.config) as Record; + } catch { + return res + .status(400) + .json({ success: false, error: "Invalid channel config" }); + } + + if (row.type === "webhook") { + await sendWebhook( + config as unknown as Parameters[0], + testPayload, + ); + } else if (row.type === "ntfy") { + await sendNtfy( + config as unknown as Parameters[0], + testPayload, + ); + } + res.json({ success: true }); + } catch (err) { + res.json({ + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } + }, +); + +// ---- Alert Rules ---- + +/** + * @openapi + * /alert-rules: + * get: + * summary: List alert rules for the current user + * tags: + * - Alerts + */ +router.get("/alert-rules", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const rules = getDb() + .$client.prepare( + "SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC", + ) + .all(userId) as Array<{ id: number }>; + + const channelMap = new Map(); + for (const rule of rules) { + const channels = getDb() + .$client.prepare( + "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", + ) + .all(rule.id) as Array<{ channel_id: number }>; + channelMap.set( + rule.id, + channels.map((c) => c.channel_id), + ); + } + + const result = rules.map((r) => ({ + ...r, + channels: channelMap.get(r.id) ?? [], + })); + res.json(result); + } catch (err) { + databaseLogger.error("Failed to list alert rules", { + operation: "list_alert_rules", + error: err, + }); + res.status(500).json({ error: "Failed to list alert rules" }); + } +}); + +/** + * @openapi + * /alert-rules: + * post: + * summary: Create an alert rule + * tags: + * - Alerts + */ +router.post("/alert-rules", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { + name, + hostId, + enabled, + triggerType, + thresholdValue, + thresholdDurationSeconds, + cooldownMinutes, + channels = [], + } = req.body as { + name: string; + hostId?: number | null; + enabled?: boolean; + triggerType: string; + thresholdValue?: number | null; + thresholdDurationSeconds?: number | null; + cooldownMinutes?: number; + channels?: number[]; + }; + + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ error: "name is required" }); + } + if (!VALID_TRIGGER_TYPES.has(triggerType)) { + return res.status(400).json({ error: "Invalid triggerType" }); + } + if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) { + return res + .status(400) + .json({ error: "thresholdValue must be between 0 and 100" }); + } + if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) { + return res + .status(400) + .json({ error: "thresholdDurationSeconds must be >= 0" }); + } + + try { + const now = new Date().toISOString(); + const result = getDb() + .$client.prepare( + `INSERT INTO alert_rules + (user_id, host_id, name, enabled, trigger_type, threshold_value, + threshold_duration_seconds, cooldown_minutes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + userId, + hostId ?? null, + name.trim(), + enabled !== false ? 1 : 0, + triggerType, + thresholdValue ?? null, + thresholdDurationSeconds ?? null, + cooldownMinutes ?? 15, + now, + now, + ); + const ruleId = result.lastInsertRowid as number; + + for (const channelId of channels) { + const owned = getDb() + .$client.prepare( + "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", + ) + .get(channelId, userId); + if (!owned) continue; + getDb() + .$client.prepare( + "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", + ) + .run(ruleId, channelId); + } + + const row = getDb() + .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") + .get(ruleId) as Record; + DatabaseSaveTrigger.triggerSave("alert_rule_created"); + res.status(201).json({ ...row, channels }); + } catch (err) { + databaseLogger.error("Failed to create alert rule", { + operation: "create_alert_rule", + error: err, + }); + res.status(500).json({ error: "Failed to create alert rule" }); + } +}); + +/** + * @openapi + * /alert-rules/{id}: + * put: + * summary: Update an alert rule + * tags: + * - Alerts + */ +router.put("/alert-rules/:id", (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const ruleId = Number(req.params.id); + + const existing = getDb() + .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") + .get(ruleId, userId); + if (!existing) return res.status(404).json({ error: "Alert rule not found" }); + + const { + name, + hostId, + enabled, + triggerType, + thresholdValue, + thresholdDurationSeconds, + cooldownMinutes, + channels, + } = req.body as { + name?: string; + hostId?: number | null; + enabled?: boolean; + triggerType?: string; + thresholdValue?: number | null; + thresholdDurationSeconds?: number | null; + cooldownMinutes?: number; + channels?: number[]; + }; + + if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) { + return res.status(400).json({ error: "Invalid triggerType" }); + } + + try { + const updates: string[] = ["updated_at = ?"]; + const params: unknown[] = [new Date().toISOString()]; + if (name !== undefined) { + updates.push("name = ?"); + params.push(name.trim()); + } + if (hostId !== undefined) { + updates.push("host_id = ?"); + params.push(hostId ?? null); + } + if (enabled !== undefined) { + updates.push("enabled = ?"); + params.push(enabled ? 1 : 0); + } + if (triggerType !== undefined) { + updates.push("trigger_type = ?"); + params.push(triggerType); + } + if (thresholdValue !== undefined) { + updates.push("threshold_value = ?"); + params.push(thresholdValue ?? null); + } + if (thresholdDurationSeconds !== undefined) { + updates.push("threshold_duration_seconds = ?"); + params.push(thresholdDurationSeconds ?? null); + } + if (cooldownMinutes !== undefined) { + updates.push("cooldown_minutes = ?"); + params.push(cooldownMinutes); + } + params.push(ruleId, userId); + + getDb() + .$client.prepare( + `UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, + ) + .run(...params); + + if (channels !== undefined) { + getDb() + .$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?") + .run(ruleId); + for (const channelId of channels) { + const owned = getDb() + .$client.prepare( + "SELECT id FROM notification_channels WHERE id = ? AND user_id = ?", + ) + .get(channelId, userId); + if (!owned) continue; + getDb() + .$client.prepare( + "INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)", + ) + .run(ruleId, channelId); + } + } + + const row = getDb() + .$client.prepare("SELECT * FROM alert_rules WHERE id = ?") + .get(ruleId) as Record; + const linkedChannels = ( + getDb() + .$client.prepare( + "SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?", + ) + .all(ruleId) as Array<{ channel_id: number }> + ).map((c) => c.channel_id); + + DatabaseSaveTrigger.triggerSave("alert_rule_updated"); + res.json({ ...row, channels: linkedChannels }); + } catch (err) { + databaseLogger.error("Failed to update alert rule", { + operation: "update_alert_rule", + error: err, + }); + res.status(500).json({ error: "Failed to update alert rule" }); + } +}); + +/** + * @openapi + * /alert-rules/{id}: + * delete: + * summary: Delete an alert rule + * tags: + * - Alerts + */ +router.delete("/alert-rules/:id", (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const ruleId = Number(req.params.id); + const existing = getDb() + .$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?") + .get(ruleId, userId); + if (!existing) return res.status(404).json({ error: "Alert rule not found" }); + getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId); + DatabaseSaveTrigger.triggerSave("alert_rule_deleted"); + res.json({ success: true }); +}); + +// ---- Alert Firings ---- + +/** + * @openapi + * /alert-firings: + * get: + * summary: List alert firings for the current user + * tags: + * - Alerts + */ +router.get("/alert-firings", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const limit = Math.min(Number(req.query.limit) || 50, 200); + const offset = Number(req.query.offset) || 0; + const acknowledgedParam = req.query.acknowledged; + + try { + let whereClause = "af.user_id = ?"; + const params: unknown[] = [userId]; + + if (acknowledgedParam === "true") { + whereClause += " AND af.acknowledged = 1"; + } else if (acknowledgedParam === "false") { + whereClause += " AND af.acknowledged = 0"; + } + + const rows = getDb() + .$client.prepare( + `SELECT af.*, ar.name as rule_name + FROM alert_firings af + LEFT JOIN alert_rules ar ON ar.id = af.rule_id + WHERE ${whereClause} + ORDER BY af.fired_at DESC + LIMIT ? OFFSET ?`, + ) + .all(...params, limit, offset); + + const total = ( + getDb() + .$client.prepare( + `SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`, + ) + .get(...params) as { c: number } + ).c; + + res.json({ firings: rows, total }); + } catch (err) { + databaseLogger.error("Failed to list alert firings", { + operation: "list_alert_firings", + error: err, + }); + res.status(500).json({ error: "Failed to list alert firings" }); + } +}); + +/** + * @openapi + * /alert-firings/{id}/acknowledge: + * post: + * summary: Acknowledge an alert firing + * tags: + * - Alerts + */ +router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const firingId = Number(req.params.id); + getDb() + .$client.prepare( + "UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?", + ) + .run(firingId, userId); + DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged"); + res.json({ success: true }); +}); + +/** + * @openapi + * /alert-firings/acknowledge-all: + * post: + * summary: Acknowledge all alert firings for the current user + * tags: + * - Alerts + */ +router.post("/alert-firings/acknowledge-all", (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + getDb() + .$client.prepare( + "UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?", + ) + .run(userId); + DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all"); + res.json({ success: true }); +}); + +export default router; diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts index 40db47e8..6d04da0b 100644 --- a/src/backend/database/routes/credential-deploy-routes.ts +++ b/src/backend/database/routes/credential-deploy-routes.ts @@ -7,6 +7,8 @@ import { eq } from "drizzle-orm"; import ssh2Pkg from "ssh2"; import { db } from "../db/index.js"; import { hosts, sshCredentials } from "../db/schema.js"; +import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js"; +import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; const { Client } = ssh2Pkg; @@ -263,97 +265,100 @@ async function deploySSHKeyToHost( resolve({ success: false, error: errorMessage }); }); - try { - const connectionConfig: Record = { - host: hostConfig.ip, - port: hostConfig.port || 22, - username: hostConfig.username, - readyTimeout: 60000, - keepaliveInterval: 30000, - keepaliveCountMax: 3, - tcpKeepAlive: true, - tcpKeepAliveInitialDelay: 30000, - algorithms: { - kex: [ - "diffie-hellman-group14-sha256", - "diffie-hellman-group14-sha1", - "diffie-hellman-group1-sha1", - "diffie-hellman-group-exchange-sha256", - "diffie-hellman-group-exchange-sha1", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521", - ], - cipher: [ - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-gcm@openssh.com", - "aes256-gcm@openssh.com", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc", - "3des-cbc", - ], - hmac: [ - "hmac-sha2-256-etm@openssh.com", - "hmac-sha2-512-etm@openssh.com", - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1", - "hmac-md5", - ], - compress: ["none", "zlib@openssh.com", "zlib"], - }, - }; + void (async () => { + try { + const connectionConfig: Record = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.username, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + algorithms: { + kex: [ + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group1-sha1", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group-exchange-sha1", + "ecdh-sha2-nistp256", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp521", + ], + cipher: [ + "aes128-ctr", + "aes192-ctr", + "aes256-ctr", + "aes128-gcm@openssh.com", + "aes256-gcm@openssh.com", + "aes128-cbc", + "aes192-cbc", + "aes256-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256", + "hmac-sha2-512", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; - if (hostConfig.authType === "password" && hostConfig.password) { - connectionConfig.password = hostConfig.password; - } else if (hostConfig.authType === "key" && hostConfig.privateKey) { - try { - const privateKey = hostConfig.privateKey as string; - if ( - !privateKey.includes("-----BEGIN") || - !privateKey.includes("-----END") - ) { - throw new Error("Invalid private key format"); + if (hostConfig.authType === "password" && hostConfig.password) { + connectionConfig.password = hostConfig.password; + } else if (hostConfig.authType === "key" && hostConfig.privateKey) { + try { + const privateKey = hostConfig.privateKey as string; + connectionConfig.privateKey = preparePrivateKeyForSSH2( + privateKey, + hostConfig.keyPassword as string | undefined, + ); + + if (hostConfig.keyPassword) { + connectionConfig.passphrase = hostConfig.keyPassword; + } + } catch (keyError) { + clearTimeout(connectionTimeout); + resolve({ + success: false, + error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, + }); + return; } - - const cleanKey = privateKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - - connectionConfig.privateKey = Buffer.from(cleanKey, "utf8"); - - if (hostConfig.keyPassword) { - connectionConfig.passphrase = hostConfig.keyPassword; + } else if (hostConfig.authType === "agent") { + const result = await applyAgentAuth( + connectionConfig, + hostConfig.terminalConfig as Record | undefined, + ); + if ("error" in result) { + clearTimeout(connectionTimeout); + resolve({ success: false, error: result.error }); + return; } - } catch (keyError) { + } else { clearTimeout(connectionTimeout); resolve({ success: false, - error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, + error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, }); return; } - } else { + + conn.connect(connectionConfig); + } catch (error) { clearTimeout(connectionTimeout); resolve({ success: false, - error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, + error: error instanceof Error ? error.message : "Connection failed", }); - return; } - - conn.connect(connectionConfig); - } catch (error) { - clearTimeout(connectionTimeout); - resolve({ - success: false, - error: error instanceof Error ? error.message : "Connection failed", - }); - } + })(); }); } @@ -479,6 +484,7 @@ export function registerCredentialDeployRoutes( password: hostData.password, privateKey: hostData.key, keyPassword: hostData.keyPassword, + terminalConfig: hostData.terminalConfig, }; if (hostData.authType === "credential" && hostData.credentialId) { diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index d0b55478..e8d767a3 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -137,8 +137,7 @@ router.post( .status(400) .json({ error: "SSH key is required for key authentication" }); } - const plainPassword = - authType === "password" && password ? password : null; + const plainPassword = password ? password : null; const plainKey = authType === "key" && key ? key : null; const plainKeyPassword = authType === "key" && keyPassword ? keyPassword : null; @@ -156,7 +155,7 @@ router.post( return res.status(400).json({ error: keyInfo.error ? `Invalid SSH key: ${keyInfo.error}` - : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).", + : "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.", }); } } @@ -514,10 +513,11 @@ router.put( if (updateData.password !== undefined) { updateFields.password = updateData.password || null; } + const nextAuthType = updateData.authType ?? existing[0].authType; if (updateData.key !== undefined) { updateFields.key = updateData.key || null; - if (updateData.key && existing[0].authType === "key") { + if (updateData.key && nextAuthType === "key") { const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword); if (!keyInfo.success) { authLogger.warn("SSH key parsing failed during update", { @@ -529,7 +529,7 @@ router.put( return res.status(400).json({ error: keyInfo.error ? `Invalid SSH key: ${keyInfo.error}` - : "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).", + : "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.", }); } updateFields.privateKey = keyInfo.privateKey; diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts index e7dacebe..a8909a3d 100644 --- a/src/backend/database/routes/dashboard-service-links-routes.ts +++ b/src/backend/database/routes/dashboard-service-links-routes.ts @@ -5,19 +5,14 @@ import { dashboardLogger } from "../../utils/logger.js"; import { db } from "../db/index.js"; import { dashboardServiceLinks } from "../db/schema.js"; import { isNonEmptyString } from "./host-normalizers.js"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "./service-link-url.js"; import express from "express"; export const dashboardServiceLinksRouter = express.Router(); -function isValidUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; - } -} - /** * @openapi * /service-links: @@ -84,7 +79,8 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { if (!isNonEmptyString(label) || !isNonEmptyString(url)) { return res.status(400).json({ error: "label and url are required" }); } - if (!isValidUrl(url)) { + const normalizedUrl = normalizeServiceLinkUrl(url); + if (!isValidServiceLinkUrl(normalizedUrl)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); @@ -105,7 +101,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => { .values({ userId, label: label.trim(), - url: url.trim(), + url: normalizedUrl, order: nextOrder, createdAt: new Date().toISOString(), }) @@ -233,7 +229,10 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { if (isNaN(id)) { return res.status(400).json({ error: "Invalid id" }); } - if (url !== undefined && !isValidUrl(url)) { + const normalizedUrl = isNonEmptyString(url) + ? normalizeServiceLinkUrl(url) + : undefined; + if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) { return res .status(400) .json({ error: "url must be a valid http or https URL" }); @@ -256,7 +255,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => { const updates: Partial<{ label: string; url: string }> = {}; if (isNonEmptyString(label)) updates.label = label.trim(); - if (isNonEmptyString(url)) updates.url = url.trim(); + if (normalizedUrl !== undefined) updates.url = normalizedUrl; if (Object.keys(updates).length === 0) { return res.status(400).json({ error: "Nothing to update" }); diff --git a/src/backend/database/routes/homepage-favicon-routes.ts b/src/backend/database/routes/homepage-favicon-routes.ts new file mode 100644 index 00000000..8f048ee7 --- /dev/null +++ b/src/backend/database/routes/homepage-favicon-routes.ts @@ -0,0 +1,103 @@ +import type { Request, Response } from "express"; +import { homepageLogger } from "../../utils/logger.js"; +import express from "express"; +import https from "https"; +import http from "http"; + +export const homepageFaviconRouter = express.Router(); + +// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number } +const faviconCache = new Map< + string, + { data: Buffer; contentType: string; expires: number } +>(); +const CACHE_SIZE = 100; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +function evictIfNeeded() { + if (faviconCache.size >= CACHE_SIZE) { + const oldest = faviconCache.keys().next().value; + if (oldest) faviconCache.delete(oldest); + } +} + +function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> { + return new Promise((resolve, reject) => { + const mod = url.startsWith("https") ? https : http; + const req = mod.get(url, { timeout: 5000 }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + resolve({ + data: Buffer.concat(chunks), + contentType: res.headers["content-type"] || "image/x-icon", + }); + }); + res.on("error", reject); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Favicon fetch timeout")); + }); + }); +} + +/** + * @openapi + * /homepage/favicon: + * get: + * summary: Proxy favicon fetch + * description: Fetches and caches a site favicon server-side to avoid CORS issues. + * tags: + * - Homepage + * parameters: + * - in: query + * name: url + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Favicon image. + * 400: + * description: Invalid URL. + * 500: + * description: Failed to fetch favicon. + */ +homepageFaviconRouter.get("/", async (req: Request, res: Response) => { + const rawUrl = req.query.url as string; + if (!rawUrl) return res.status(400).json({ error: "url is required" }); + + let domain: string; + try { + domain = new URL(rawUrl).hostname; + } catch { + return res.status(400).json({ error: "Invalid URL" }); + } + + const cached = faviconCache.get(domain); + if (cached && cached.expires > Date.now()) { + res.setHeader("Content-Type", cached.contentType); + res.setHeader("Cache-Control", "public, max-age=86400"); + return res.send(cached.data); + } + + const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`; + + try { + const { data, contentType } = await fetchUrl(faviconUrl); + evictIfNeeded(); + faviconCache.set(domain, { + data, + contentType, + expires: Date.now() + CACHE_TTL_MS, + }); + res.setHeader("Content-Type", contentType); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.send(data); + } catch (err) { + homepageLogger.warn("Failed to fetch favicon", { domain }); + res.status(500).json({ error: "Failed to fetch favicon" }); + } +}); diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts new file mode 100644 index 00000000..a38a6c1f --- /dev/null +++ b/src/backend/database/routes/homepage-items-routes.ts @@ -0,0 +1,225 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { Request, Response } from "express"; +import { and, asc, eq } from "drizzle-orm"; +import { homepageLogger } from "../../utils/logger.js"; +import { db } from "../db/index.js"; +import { homepageItems } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import express from "express"; + +export const homepageItemsRouter = express.Router(); + +/** + * @openapi + * /homepage/items: + * get: + * summary: Get homepage items + * description: Returns all homepage widget items for the authenticated user. + * tags: + * - Homepage + * responses: + * 200: + * description: List of homepage items. + * 500: + * description: Failed to fetch homepage items. + */ +homepageItemsRouter.get("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const items = await db + .select() + .from(homepageItems) + .where(eq(homepageItems.userId, userId)) + .orderBy(asc(homepageItems.id)); + res.json(items); + } catch (err) { + homepageLogger.error("Failed to fetch homepage items", err); + res.status(500).json({ error: "Failed to fetch homepage items" }); + } +}); + +/** + * @openapi + * /homepage/items: + * post: + * summary: Create homepage item + * description: Creates a new homepage widget item for the authenticated user. + * tags: + * - Homepage + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - typeId + * properties: + * typeId: + * type: string + * title: + * type: string + * config: + * type: object + * responses: + * 201: + * description: Homepage item created. + * 400: + * description: Invalid data. + * 500: + * description: Failed to create homepage item. + */ +homepageItemsRouter.post("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { typeId, title, config } = req.body; + + if (!typeId || typeof typeId !== "string") { + return res.status(400).json({ error: "typeId is required" }); + } + + try { + const [created] = await db + .insert(homepageItems) + .values({ + userId, + typeId, + title: title ?? null, + config: config ? JSON.stringify(config) : "{}", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .returning(); + DatabaseSaveTrigger.triggerSave("homepage_item_created"); + res.status(201).json(created); + } catch (err) { + homepageLogger.error("Failed to create homepage item", err); + res.status(500).json({ error: "Failed to create homepage item" }); + } +}); + +/** + * @openapi + * /homepage/items/{id}: + * put: + * summary: Update homepage item + * description: Updates a homepage widget item. + * tags: + * - Homepage + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * config: + * type: object + * responses: + * 200: + * description: Homepage item updated. + * 400: + * description: Invalid data. + * 404: + * description: Not found. + * 500: + * description: Failed to update homepage item. + */ +homepageItemsRouter.put("/:id", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(req.params.id as string); + if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); + + const { title, config } = req.body; + + try { + const existing = await db + .select() + .from(homepageItems) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); + + if (existing.length === 0) { + return res.status(404).json({ error: "Not found" }); + } + + const updates: Partial<{ + title: string | null; + config: string; + updatedAt: string; + }> = { + updatedAt: new Date().toISOString(), + }; + if (title !== undefined) updates.title = title; + if (config !== undefined) updates.config = JSON.stringify(config); + + const [updated] = await db + .update(homepageItems) + .set(updates) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) + .returning(); + + DatabaseSaveTrigger.triggerSave("homepage_item_updated"); + res.json(updated); + } catch (err) { + homepageLogger.error("Failed to update homepage item", err); + res.status(500).json({ error: "Failed to update homepage item" }); + } +}); + +/** + * @openapi + * /homepage/items/{id}: + * delete: + * summary: Delete homepage item + * description: Deletes a homepage widget item and cascades deletion to children if a folder. + * tags: + * - Homepage + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Homepage item deleted. + * 400: + * description: Invalid id. + * 404: + * description: Not found. + * 500: + * description: Failed to delete homepage item. + */ +homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(req.params.id as string); + if (isNaN(id)) return res.status(400).json({ error: "Invalid id" }); + + try { + const existing = await db + .select() + .from(homepageItems) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); + + if (existing.length === 0) { + return res.status(404).json({ error: "Not found" }); + } + + await db + .delete(homepageItems) + .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))); + + DatabaseSaveTrigger.triggerSave("homepage_item_deleted"); + res.json({ message: "Homepage item deleted" }); + } catch (err) { + homepageLogger.error("Failed to delete homepage item", err); + res.status(500).json({ error: "Failed to delete homepage item" }); + } +}); diff --git a/src/backend/database/routes/homepage-layout-routes.ts b/src/backend/database/routes/homepage-layout-routes.ts new file mode 100644 index 00000000..322917cb --- /dev/null +++ b/src/backend/database/routes/homepage-layout-routes.ts @@ -0,0 +1,110 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { Request, Response } from "express"; +import { eq } from "drizzle-orm"; +import { homepageLogger } from "../../utils/logger.js"; +import { db } from "../db/index.js"; +import { homepageLayouts } from "../db/schema.js"; +import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; +import express from "express"; + +export const homepageLayoutRouter = express.Router(); + +/** + * @openapi + * /homepage/layout: + * get: + * summary: Get homepage layout + * description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user. + * tags: + * - Homepage + * responses: + * 200: + * description: Layout data or null. + * 500: + * description: Failed to fetch homepage layout. + */ +homepageLayoutRouter.get("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const rows = await db + .select() + .from(homepageLayouts) + .where(eq(homepageLayouts.userId, userId)); + + if (rows.length === 0) { + return res.json(null); + } + + const row = rows[0]; + const parsed = JSON.parse(row.layout || "{}"); + res.json({ ...row, layout: parsed }); + } catch (err) { + homepageLogger.error("Failed to fetch homepage layout", err); + res.status(500).json({ error: "Failed to fetch homepage layout" }); + } +}); + +/** + * @openapi + * /homepage/layout: + * put: + * summary: Save homepage layout + * description: Saves or updates the homepage canvas layout for the authenticated user. + * tags: + * - Homepage + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * entries: + * type: array + * pan: + * type: object + * zoom: + * type: number + * responses: + * 200: + * description: Layout saved. + * 500: + * description: Failed to save homepage layout. + */ +homepageLayoutRouter.put("/", async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const layoutData = req.body; + + try { + const existing = await db + .select({ id: homepageLayouts.id }) + .from(homepageLayouts) + .where(eq(homepageLayouts.userId, userId)); + + const layoutJson = JSON.stringify(layoutData); + const now = new Date().toISOString(); + + if (existing.length === 0) { + const [created] = await db + .insert(homepageLayouts) + .values({ userId, layout: layoutJson, updatedAt: now }) + .returning(); + const parsed = JSON.parse(created.layout); + DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); + return res.json({ ...created, layout: parsed }); + } + + const [updated] = await db + .update(homepageLayouts) + .set({ layout: layoutJson, updatedAt: now }) + .where(eq(homepageLayouts.userId, userId)) + .returning(); + + const parsed = JSON.parse(updated.layout); + DatabaseSaveTrigger.triggerSave("homepage_layout_saved"); + res.json({ ...updated, layout: parsed }); + } catch (err) { + homepageLogger.error("Failed to save homepage layout", err); + res.status(500).json({ error: "Failed to save homepage layout" }); + } +}); diff --git a/src/backend/database/routes/homepage-ping-routes.ts b/src/backend/database/routes/homepage-ping-routes.ts new file mode 100644 index 00000000..7b2995c2 --- /dev/null +++ b/src/backend/database/routes/homepage-ping-routes.ts @@ -0,0 +1,127 @@ +import type { Request, Response } from "express"; +import express from "express"; +import https from "https"; +import http from "http"; +import { homepageLogger } from "../../utils/logger.js"; + +export const homepagePingRouter = express.Router(); + +interface PingCacheEntry { + ok: boolean; + statusCode: number | null; + latencyMs: number; + expires: number; +} + +const pingCache = new Map(); +const CACHE_SIZE = 200; +const FETCH_TIMEOUT_MS = 5000; + +function pingUrl( + url: string, +): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> { + return new Promise((resolve) => { + const start = performance.now(); + const mod = url.startsWith("https") ? https : http; + + const done = (ok: boolean, statusCode: number | null) => { + resolve({ + ok, + statusCode, + latencyMs: Math.round(performance.now() - start), + }); + }; + + const tryGet = () => { + const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => { + res.resume(); + const code = res.statusCode ?? null; + done(code !== null && code < 400, code); + }); + req.on("error", () => done(false, null)); + req.on("timeout", () => { + req.destroy(); + done(false, null); + }); + }; + + const req = mod.request( + url, + { method: "HEAD", timeout: FETCH_TIMEOUT_MS }, + (res) => { + res.resume(); + const code = res.statusCode ?? null; + if (code === 405) { + tryGet(); + } else { + done(code !== null && code < 400, code); + } + }, + ); + req.on("error", () => done(false, null)); + req.on("timeout", () => { + req.destroy(); + done(false, null); + }); + req.end(); + }); +} + +/** + * @openapi + * /homepage/ping: + * get: + * summary: Check the HTTP reachability and latency of a URL + * tags: + * - Homepage + * parameters: + * - in: query + * name: url + * required: true + * schema: + * type: string + * - in: query + * name: ttl + * schema: + * type: integer + * description: Cache TTL in seconds (min 10) + * responses: + * 200: + * description: Ping result with ok, statusCode and latencyMs. + * 400: + * description: Invalid or missing URL. + */ +homepagePingRouter.get("/", async (req: Request, res: Response) => { + let targetUrl = req.query.url as string; + const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000; + + if (!targetUrl) return res.status(400).json({ error: "url is required" }); + if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`; + try { + new URL(targetUrl); + } catch { + return res.status(400).json({ error: "Invalid URL" }); + } + + const cached = pingCache.get(targetUrl); + if (cached && cached.expires > Date.now()) { + return res.json({ + ok: cached.ok, + statusCode: cached.statusCode, + latencyMs: cached.latencyMs, + }); + } + + try { + const result = await pingUrl(targetUrl); + if (pingCache.size >= CACHE_SIZE) { + const oldest = pingCache.keys().next().value; + if (oldest) pingCache.delete(oldest); + } + pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl }); + res.json(result); + } catch (err) { + homepageLogger.warn("Ping failed", { targetUrl }); + res.status(500).json({ error: "Ping failed" }); + } +}); diff --git a/src/backend/database/routes/homepage-proxy-routes.ts b/src/backend/database/routes/homepage-proxy-routes.ts new file mode 100644 index 00000000..72115317 --- /dev/null +++ b/src/backend/database/routes/homepage-proxy-routes.ts @@ -0,0 +1,100 @@ +import type { Request, Response } from "express"; +import express from "express"; +import https from "https"; +import http from "http"; +import { homepageLogger } from "../../utils/logger.js"; + +export const homepageProxyRouter = express.Router(); + +interface ProxyCacheEntry { + data: unknown; + expires: number; +} + +const proxyCache = new Map(); +const CACHE_SIZE = 50; +const FETCH_TIMEOUT_MS = 8000; + +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const mod = url.startsWith("https") ? https : http; + const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + try { + const text = Buffer.concat(chunks).toString("utf-8"); + resolve(JSON.parse(text)); + } catch { + reject(new Error("Response is not valid JSON")); + } + }); + res.on("error", reject); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Fetch timeout")); + }); + }); +} + +/** + * @openapi + * /homepage/proxy: + * get: + * summary: Proxy a JSON API URL and return the parsed response + * tags: + * - Homepage + * parameters: + * - in: query + * name: url + * required: true + * schema: + * type: string + * - in: query + * name: ttl + * schema: + * type: integer + * description: Cache TTL in seconds (min 10, default 60) + * responses: + * 200: + * description: The JSON body returned by the target URL. + * 400: + * description: Invalid or missing URL, or non-JSON response. + * 500: + * description: Failed to fetch the target URL. + */ +homepageProxyRouter.get("/", async (req: Request, res: Response) => { + const targetUrl = req.query.url as string; + const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000; + + if (!targetUrl) return res.status(400).json({ error: "url is required" }); + try { + new URL(targetUrl); + } catch { + return res.status(400).json({ error: "Invalid URL" }); + } + + const cached = proxyCache.get(targetUrl); + if (cached && cached.expires > Date.now()) { + return res.json(cached.data); + } + + try { + const data = await fetchJson(targetUrl); + if (proxyCache.size >= CACHE_SIZE) { + const oldest = proxyCache.keys().next().value; + if (oldest) proxyCache.delete(oldest); + } + proxyCache.set(targetUrl, { data, expires: Date.now() + ttl }); + res.json(data); + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + homepageLogger.warn("Proxy fetch failed", { targetUrl, msg }); + if (msg.includes("not valid JSON")) { + return res.status(400).json({ error: "Response is not valid JSON" }); + } + res.status(500).json({ error: "Failed to fetch URL" }); + } +}); diff --git a/src/backend/database/routes/homepage-rss-routes.ts b/src/backend/database/routes/homepage-rss-routes.ts new file mode 100644 index 00000000..7312243c --- /dev/null +++ b/src/backend/database/routes/homepage-rss-routes.ts @@ -0,0 +1,148 @@ +import type { Request, Response } from "express"; +import express from "express"; +import https from "https"; +import http from "http"; +import { homepageLogger } from "../../utils/logger.js"; + +export const homepageRssRouter = express.Router(); + +const rssCache = new Map(); +const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes +const CACHE_SIZE = 50; +const FETCH_TIMEOUT_MS = 8000; + +interface RssItem { + title: string; + link: string; + pubDate: string | null; + description: string | null; +} + +function fetchXml(url: string): Promise { + return new Promise((resolve, reject) => { + const mod = url.startsWith("https") ? https : http; + const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + res.on("error", reject); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("RSS fetch timeout")); + }); + }); +} + +function parseRss(xml: string): RssItem[] { + const items: RssItem[] = []; + const itemRegex = /]([\s\S]*?)<\/item>/gi; + let match: RegExpExecArray | null; + + const getText = (tag: string, src: string): string | null => { + const m = new RegExp( + `<${tag}[^>]*><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, + "i", + ).exec(src); + if (!m) return null; + return (m[1] ?? m[2]).trim(); + }; + + const getLink = (src: string): string => { + // Self-closing (BBC style) + const selfClose = /]+href="([^"]+)"[^>]*\/?>/i.exec(src); + if (selfClose) return selfClose[1]; + // Plain text url + return getText("link", src) ?? ""; + }; + + while ((match = itemRegex.exec(xml)) !== null) { + const src = match[1]; + items.push({ + title: getText("title", src) ?? "(no title)", + link: getLink(src), + pubDate: getText("pubDate", src) ?? getText("updated", src), + description: getText("description", src) ?? getText("summary", src), + }); + if (items.length >= 50) break; + } + + // Atom feed fallback + if (items.length === 0) { + const entryRegex = /]([\s\S]*?)<\/entry>/gi; + while ((match = entryRegex.exec(xml)) !== null) { + const src = match[1]; + const linkMatch = /]+href="([^"]+)"/.exec(src); + items.push({ + title: getText("title", src) ?? "(no title)", + link: linkMatch?.[1] ?? "", + pubDate: getText("published", src) ?? getText("updated", src), + description: getText("summary", src) ?? getText("content", src), + }); + if (items.length >= 50) break; + } + } + + return items; +} + +/** + * @openapi + * /homepage/rss: + * get: + * summary: Proxy and parse an RSS/Atom feed + * tags: + * - Homepage + * parameters: + * - in: query + * name: url + * required: true + * schema: + * type: string + * - in: query + * name: max + * schema: + * type: integer + * default: 10 + * responses: + * 200: + * description: Array of feed items. + * 400: + * description: Invalid or missing URL. + * 500: + * description: Failed to fetch or parse the feed. + */ +homepageRssRouter.get("/", async (req: Request, res: Response) => { + const feedUrl = req.query.url as string; + const max = Math.min(50, Math.max(1, Number(req.query.max) || 10)); + + if (!feedUrl) return res.status(400).json({ error: "url is required" }); + + try { + new URL(feedUrl); + } catch { + return res.status(400).json({ error: "Invalid URL" }); + } + + const cached = rssCache.get(feedUrl); + if (cached && cached.expires > Date.now()) { + return res.json(cached.data.slice(0, max)); + } + + try { + const xml = await fetchXml(feedUrl); + const items = parseRss(xml); + + if (rssCache.size >= CACHE_SIZE) { + const oldest = rssCache.keys().next().value; + if (oldest) rssCache.delete(oldest); + } + rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS }); + + res.json(items.slice(0, max)); + } catch (err) { + homepageLogger.warn("Failed to fetch RSS feed", { feedUrl }); + res.status(500).json({ error: "Failed to fetch feed" }); + } +}); diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 54cbf1fd..b7acef60 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -20,6 +20,35 @@ type SSHConfigHost = { proxyJump?: string; }; +type ShareCredential = { + alias?: unknown; + name?: unknown; + description?: unknown; + folder?: unknown; + tags?: unknown; + authType?: unknown; + username?: unknown; + keyType?: unknown; +}; + +function textValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function tagString(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((tag) => textValue(tag)) + .filter((tag): tag is string => !!tag) + .join(","); + } + return textValue(value) || ""; +} + +function normalizeCredentialAuthType(value: unknown): "password" | "key" { + return value === "key" ? "key" : "password"; +} + export function parseSSHConfig(content: string): SSHConfigHost[] { const results: SSHConfigHost[] = []; let current: SSHConfigHost | null = null; @@ -280,7 +309,11 @@ export function registerHostBulkRoutes( authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { hosts: hostsToImport, overwrite } = req.body; + const { + hosts: hostsToImport, + overwrite, + credentials: credentialsToImport, + } = req.body; if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { return res @@ -302,6 +335,80 @@ export function registerHostBulkRoutes( errors: [] as string[], }; + const credentialAliasMap = new Map(); + const addCredentialAlias = (alias: unknown, id: number) => { + const key = textValue(alias); + if (key) credentialAliasMap.set(key.toLowerCase(), id); + }; + + try { + const existingCredentials = await SimpleDBOps.select< + Record + >( + db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)), + "ssh_credentials", + userId, + ); + + for (const credential of existingCredentials) { + addCredentialAlias(credential.name, credential.id as number); + } + + if (Array.isArray(credentialsToImport)) { + for (const rawCredential of credentialsToImport as ShareCredential[]) { + const alias = textValue(rawCredential.alias); + const name = textValue(rawCredential.name) || alias; + if (!alias || !name) continue; + + const existingId = credentialAliasMap.get(name.toLowerCase()); + if (existingId) { + addCredentialAlias(alias, existingId); + continue; + } + + const now = new Date().toISOString(); + const created = await SimpleDBOps.insert( + sshCredentials, + "ssh_credentials", + { + userId, + name, + description: + textValue(rawCredential.description) || + "Imported placeholder. Add the secret before connecting.", + folder: textValue(rawCredential.folder), + tags: tagString(rawCredential.tags), + authType: normalizeCredentialAuthType(rawCredential.authType), + username: textValue(rawCredential.username), + password: null, + key: null, + privateKey: null, + publicKey: null, + keyPassword: null, + keyType: textValue(rawCredential.keyType), + detectedKeyType: null, + usageCount: 0, + lastUsed: null, + createdAt: now, + updatedAt: now, + }, + userId, + ); + + const createdCredential = created as Record; + addCredentialAlias(alias, createdCredential.id as number); + addCredentialAlias(name, createdCredential.id as number); + } + } + } catch (error) { + results.errors.push( + `Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`, + ); + } + let existingHostMap: Map | undefined; if (overwrite) { try { @@ -326,6 +433,17 @@ export function registerHostBulkRoutes( try { const effectiveConnectionType = hostData.connectionType || "ssh"; + if ( + effectiveConnectionType === "ssh" && + hostData.authType === "credential" && + !hostData.credentialId && + hostData.credentialAlias + ) { + hostData.credentialId = credentialAliasMap.get( + hostData.credentialAlias.toLowerCase(), + ); + } + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { results.failed++; results.errors.push( @@ -355,11 +473,12 @@ export function registerHostBulkRoutes( "none", "opkssh", "tailscale", + "vault", ].includes(hostData.authType) ) { results.failed++; results.errors.push( - `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', or 'tailscale'`, + `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'tailscale', or 'vault'`, ); continue; } diff --git a/src/backend/database/routes/host-normalizers.test.ts b/src/backend/database/routes/host-normalizers.test.ts index d406951e..7dae39f6 100644 --- a/src/backend/database/routes/host-normalizers.test.ts +++ b/src/backend/database/routes/host-normalizers.test.ts @@ -122,6 +122,16 @@ describe("normalizeImportedHost", () => { expect(host.credentialId).toBe(7); expect(host.authType).toBe("credential"); }); + + it("infers credential auth from share aliases", () => { + const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" }); + expect(aliasHost.credentialAlias).toBe("prod-admin"); + expect(aliasHost.authType).toBe("credential"); + + const nameHost = normalizeImportedHost({ credentialName: "ops-key" }); + expect(nameHost.credentialAlias).toBe("ops-key"); + expect(nameHost.authType).toBe("credential"); + }); }); describe("stripSensitiveFields", () => { diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 06978572..9dc4e1a7 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -94,6 +94,7 @@ export type NormalizedImportedHost = Record & { keyPassword?: string; keyType?: string; credentialId?: number; + credentialAlias?: string; pin?: unknown; enableTerminal?: unknown; enableTunnel?: unknown; @@ -138,6 +139,8 @@ export type NormalizedImportedHost = Record & { export function normalizeImportedHost( hostData: Record, ): NormalizedImportedHost { + const credentialAlias = + asString(hostData.credentialAlias) || asString(hostData.credentialName); const connectionType = asString(hostData.connectionType) || (asBoolean(hostData.enableRdp) @@ -172,10 +175,15 @@ export function normalizeImportedHost( folder: asString(hostData.folder) || asString(hostData.group), tags: normalizeImportTags(hostData.tags), credentialId: asInteger(hostData.credentialId), + credentialAlias, authType: asString(hostData.authType) || asString(hostData.authMethod) || - (hostData.credentialId ? "credential" : hostData.key ? "key" : undefined), + (hostData.credentialId || credentialAlias + ? "credential" + : hostData.key + ? "key" + : undefined), enableSsh: hostData.enableSsh === undefined ? connectionType === "ssh" diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 06b950c4..9d38b10d 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -79,6 +79,69 @@ const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +type ShareCredentialExport = { + alias: string; + name: string; + authType: "password" | "key"; + username: string | null; + description: string | null; + folder: string | null; + tags: string[]; + keyType: string | null; +}; + +function parseJsonField(value: unknown, fallback: unknown) { + if (!value || typeof value !== "string") return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function splitTags(value: unknown): string[] { + if (Array.isArray(value)) + return value.filter((tag) => typeof tag === "string"); + if (typeof value !== "string") return []; + return value.split(",").filter(Boolean); +} + +function uniqueAlias(base: string, used: Set): string { + const normalized = + base + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") || "credential"; + let alias = normalized; + let suffix = 2; + while (used.has(alias)) { + alias = `${normalized}-${suffix++}`; + } + used.add(alias); + return alias; +} + +function safeCredentialExport( + credential: Record, + alias: string, +): ShareCredentialExport { + return { + alias, + name: String(credential.name || alias), + authType: credential.authType === "key" ? "key" : "password", + username: + typeof credential.username === "string" ? credential.username : null, + description: + typeof credential.description === "string" + ? credential.description + : null, + folder: typeof credential.folder === "string" ? credential.folder : null, + tags: splitTags(credential.tags), + keyType: typeof credential.keyType === "string" ? credential.keyType : null, + }; +} + registerHostInternalRoutes(router); /** @@ -146,6 +209,7 @@ router.post( authType, useWarpgate, credentialId, + vaultProfileId, key, keyPassword, keyType, @@ -200,6 +264,8 @@ router.post( rdpDomain, rdpSecurity, rdpIgnoreCert, + vncAuthType, + vncCredentialId, vncPassword, vncUser, telnetUser, @@ -248,6 +314,7 @@ router.post( authType: effectiveAuthType, useWarpgate: useWarpgate ? 1 : 0, credentialId: credentialId || null, + vaultProfileId: vaultProfileId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, @@ -322,6 +389,11 @@ router.post( rdpDomain: rdpDomain || null, rdpSecurity: rdpSecurity || null, rdpIgnoreCert: rdpIgnoreCert ? 1 : 0, + vncAuthType: enableVnc ? vncAuthType || null : null, + vncCredentialId: + enableVnc && vncAuthType === "credential" && vncCredentialId + ? vncCredentialId + : null, vncUser: vncUser || null, telnetUser: telnetUser || null, }; @@ -339,19 +411,6 @@ router.post( sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { if (key && typeof key === "string") { - if (!key.includes("-----BEGIN") || !key.includes("-----END")) { - sshLogger.warn("Invalid SSH key format provided", { - operation: "host_create", - userId, - name, - ip, - port, - }); - return res.status(400).json({ - error: "Invalid SSH key format. Key must be in PEM format.", - }); - } - const keyValidation = parseSSHKey( key, typeof keyPassword === "string" ? keyPassword : undefined, @@ -375,6 +434,11 @@ router.post( sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; + } else if (effectiveAuthType === "agent") { + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else { sshDataObj.password = null; sshDataObj.key = null; @@ -721,6 +785,7 @@ router.put( authType, useWarpgate, credentialId, + vaultProfileId, key, keyPassword, keyType, @@ -775,6 +840,8 @@ router.put( rdpDomain, rdpSecurity, rdpIgnoreCert, + vncAuthType, + vncCredentialId, vncPassword, vncUser, telnetUser, @@ -820,6 +887,7 @@ router.put( authType: effectiveAuthType, useWarpgate: useWarpgate ? 1 : 0, credentialId: credentialId || null, + vaultProfileId: vaultProfileId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, @@ -894,6 +962,11 @@ router.put( rdpDomain: rdpDomain || null, rdpSecurity: rdpSecurity || null, rdpIgnoreCert: rdpIgnoreCert ? 1 : 0, + vncAuthType: enableVnc ? vncAuthType || null : null, + vncCredentialId: + enableVnc && vncAuthType === "credential" && vncCredentialId + ? vncCredentialId + : null, vncUser: vncUser || null, telnetUser: telnetUser || null, }; @@ -915,20 +988,6 @@ router.put( sshDataObj.keyType = null; } else if (effectiveAuthType === "key") { if (key && typeof key === "string") { - if (!key.includes("-----BEGIN") || !key.includes("-----END")) { - sshLogger.warn("Invalid SSH key format provided", { - operation: "host_update", - hostId: parseInt(hostId), - userId, - name, - ip, - port, - }); - return res.status(400).json({ - error: "Invalid SSH key format. Key must be in PEM format.", - }); - } - const keyValidation = parseSSHKey( key, typeof keyPassword === "string" ? keyPassword : undefined, @@ -957,6 +1016,11 @@ router.put( sshDataObj.keyType = keyType; } sshDataObj.password = null; + } else if (effectiveAuthType === "agent") { + sshDataObj.password = null; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; } else { sshDataObj.password = null; sshDataObj.key = null; @@ -1209,6 +1273,7 @@ router.get( createdAt: hosts.createdAt, updatedAt: hosts.updatedAt, credentialId: hosts.credentialId, + vaultProfileId: hosts.vaultProfileId, overrideCredentialUsername: hosts.overrideCredentialUsername, quickActions: hosts.quickActions, notes: hosts.notes, @@ -1249,6 +1314,7 @@ router.get( rdpDomain: hosts.rdpDomain, rdpSecurity: hosts.rdpSecurity, rdpIgnoreCert: hosts.rdpIgnoreCert, + vncAuthType: hosts.vncAuthType, vncCredentialId: hosts.vncCredentialId, vncUser: hosts.vncUser, vncPassword: hosts.vncPassword, @@ -1444,7 +1510,7 @@ router.get( * name: field * schema: * type: string - * enum: [password, sudoPassword] + * enum: [password, sudoPassword, vncPassword] * responses: * 200: * description: The requested password value. @@ -1460,7 +1526,7 @@ router.get( const userId = (req as AuthenticatedRequest).userId; const field = (req.query.field as string) || "password"; - if (!["password", "sudoPassword"].includes(field)) { + if (!["password", "sudoPassword", "vncPassword"].includes(field)) { return res.status(400).json({ error: "Invalid field" }); } @@ -1599,6 +1665,8 @@ router.get( rdpDomain: resolvedHost.rdpDomain || null, rdpSecurity: resolvedHost.rdpSecurity || null, rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert, + vncAuthType: resolvedHost.vncAuthType || null, + vncCredentialId: resolvedHost.vncCredentialId || null, vncUser: resolvedHost.vncUser || null, vncPassword: resolvedHost.vncPassword || null, telnetUser: resolvedHost.telnetUser || null, @@ -1706,6 +1774,11 @@ router.get( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; + const shareExport = + req.query.share === "1" || + req.query.share === "true" || + req.query.safe === "1" || + req.query.safe === "true"; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid userId" }); @@ -1718,6 +1791,192 @@ router.get( userId, ); + if (shareExport) { + const credentials = await SimpleDBOps.select>( + db + .select() + .from(sshCredentials) + .where(eq(sshCredentials.userId, userId)), + "ssh_credentials", + userId, + ); + const credentialsById = new Map>(); + for (const credential of credentials) { + if (typeof credential.id === "number") { + credentialsById.set(credential.id, credential); + } + } + + const usedAliases = new Set(); + const exportedCredentials = new Map(); + const credentialIdAliases = new Map(); + const directCredentialAliases = new Map(); + + const addCredential = ( + credential: Record, + fallbackName: string, + ) => { + if (typeof credential.id === "number") { + const existing = credentialIdAliases.get(credential.id); + if (existing) return existing; + } + + const alias = uniqueAlias( + String(credential.name || fallbackName), + usedAliases, + ); + exportedCredentials.set( + alias, + safeCredentialExport(credential, alias), + ); + if (typeof credential.id === "number") { + credentialIdAliases.set(credential.id, alias); + } + return alias; + }; + + const addDirectCredential = ( + authType: "password" | "key", + username: unknown, + keyType?: unknown, + ) => { + const usernameText = + typeof username === "string" && username.trim() + ? username.trim() + : "user"; + const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`; + const existing = directCredentialAliases.get(key); + if (existing) return existing; + + const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases); + directCredentialAliases.set(key, alias); + exportedCredentials.set( + alias, + safeCredentialExport( + { + name: `${usernameText} ${authType}`, + authType, + username: usernameText, + keyType, + }, + alias, + ), + ); + return alias; + }; + + const exportedHosts = allHosts.map((host) => { + const exportedConnectionType = + (host.connectionType as string) || "ssh"; + const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes( + exportedConnectionType, + ); + + const baseExportData: Record = { + connectionType: exportedConnectionType, + name: host.name, + ip: host.ip, + port: host.port, + username: host.username, + folder: host.folder, + tags: splitTags(host.tags), + notes: host.notes || null, + }; + + if (isRemoteDesktop) { + return { + ...baseExportData, + domain: host.domain || null, + security: host.security || null, + ignoreCert: !!host.ignoreCert, + guacamoleConfig: parseJsonField(host.guacamoleConfig, null), + }; + } + + const exportData: Record = { + ...baseExportData, + authType: host.authType || "none", + enableTerminal: !!host.enableTerminal, + enableTunnel: !!host.enableTunnel, + enableFileManager: host.enableFileManager !== false, + enableDocker: !!host.enableDocker, + enableProxmox: !!host.enableProxmox, + enableTmuxMonitor: !!host.enableTmuxMonitor, + showTerminalInSidebar: !!host.showTerminalInSidebar, + showFileManagerInSidebar: !!host.showFileManagerInSidebar, + showTunnelInSidebar: !!host.showTunnelInSidebar, + showDockerInSidebar: !!host.showDockerInSidebar, + showServerStatsInSidebar: !!host.showServerStatsInSidebar, + defaultPath: host.defaultPath, + tunnelConnections: parseJsonField(host.tunnelConnections, []), + jumpHosts: parseJsonField(host.jumpHosts, null), + quickActions: parseJsonField(host.quickActions, null), + statsConfig: parseJsonField(host.statsConfig, null), + dockerConfig: parseJsonField(host.dockerConfig, null), + proxmoxConfig: parseJsonField(host.proxmoxConfig, null), + forceKeyboardInteractive: host.forceKeyboardInteractive === "true", + useSocks5: !!host.useSocks5, + socks5Host: host.socks5Host || null, + socks5Port: host.socks5Port || null, + socks5Username: host.socks5Username || null, + socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null), + portKnockSequence: parseJsonField(host.portKnockSequence, null), + overrideCredentialUsername: !!host.overrideCredentialUsername, + }; + + if (typeof host.credentialId === "number") { + const credential = credentialsById.get(host.credentialId); + if (credential) { + exportData.authType = "credential"; + exportData.credentialAlias = addCredential( + credential, + String(host.username || "credential"), + ); + return exportData; + } + } + + if (host.authType === "password") { + exportData.authType = "credential"; + exportData.credentialAlias = addDirectCredential( + "password", + host.username, + ); + return exportData; + } + + if (host.authType === "key") { + exportData.authType = "credential"; + exportData.credentialAlias = addDirectCredential( + "key", + host.username, + host.keyType, + ); + return exportData; + } + + if (host.authType === "credential") { + exportData.authType = "none"; + } + + return exportData; + }); + + sshLogger.success("All hosts exported for sharing", { + operation: "hosts_export_share", + count: exportedHosts.length, + credentialCount: exportedCredentials.size, + userId, + }); + + return res.json({ + version: "termix-host-share-v1", + exportedAt: new Date().toISOString(), + credentials: Array.from(exportedCredentials.values()), + hosts: exportedHosts, + }); + } + const exportedHosts = []; for (const host of allHosts) { diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts index a83eb794..fe4991be 100644 --- a/src/backend/database/routes/proxmox.ts +++ b/src/backend/database/routes/proxmox.ts @@ -357,6 +357,16 @@ router.post( sshConfig.privateKey = resolvedCredentials.sshKey; if (resolvedCredentials.keyPassword) sshConfig.passphrase = resolvedCredentials.keyPassword; + } else if (authType === "agent") { + const { applyAgentAuth } = + await import("../../ssh/terminal-auth-helpers.js"); + const result = await applyAgentAuth( + sshConfig, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + return res.status(400).json({ error: result.error }); + } } else if (resolvedCredentials.password) { sshConfig.password = resolvedCredentials.password; } diff --git a/src/backend/database/routes/service-link-url.test.ts b/src/backend/database/routes/service-link-url.test.ts new file mode 100644 index 00000000..353d4da6 --- /dev/null +++ b/src/backend/database/routes/service-link-url.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "./service-link-url.js"; + +describe("service link URL handling", () => { + it("keeps explicit http and https URLs", () => { + expect(normalizeServiceLinkUrl("https://example.com")).toBe( + "https://example.com", + ); + expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe( + "http://192.168.1.10:8080", + ); + }); + + it("adds http to bare service addresses", () => { + expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe( + "http://192.168.1.10:8080", + ); + expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local"); + }); + + it("rejects unsupported schemes", () => { + expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false); + expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false); + }); +}); diff --git a/src/backend/database/routes/service-link-url.ts b/src/backend/database/routes/service-link-url.ts new file mode 100644 index 00000000..cb493ae2 --- /dev/null +++ b/src/backend/database/routes/service-link-url.ts @@ -0,0 +1,16 @@ +export function normalizeServiceLinkUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; +} + +export function isValidServiceLinkUrl(value: string): boolean { + try { + const parsed = new URL(normalizeServiceLinkUrl(value)); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 5848b20e..316dd6e9 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -15,6 +15,7 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js"; import { extractSnippetReorderUpdates } from "./snippets-reorder.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js"; const router = express.Router(); @@ -773,11 +774,12 @@ router.post( let output = ""; let errorOutput = ""; + /* eslint-disable no-async-promise-executor */ const executePromise = new Promise<{ success: boolean; output: string; error?: string; - }>((resolve, reject) => { + }>(async (resolve, reject) => { const timeout = setTimeout(() => { conn.end(); reject(new Error("Command execution timeout (30s)")); @@ -886,6 +888,14 @@ router.post( if (passphrase) { config.passphrase = passphrase; } + } else if (authType === "agent") { + const result = await applyAgentAuth( + config, + host.terminalConfig as Record | string | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } else if (password) { config.password = password; } else if (privateKey) { @@ -901,6 +911,7 @@ router.post( conn.connect(config); }); + /* eslint-enable no-async-promise-executor */ const result = await executePromise; diff --git a/src/backend/database/routes/ssh-certificate.test.ts b/src/backend/database/routes/ssh-certificate.test.ts new file mode 100644 index 00000000..a884f8d7 --- /dev/null +++ b/src/backend/database/routes/ssh-certificate.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execFileSync } from "child_process"; +import { + generateCa, + signUserCertificate, + ed25519RawFromLine, +} from "./ssh-certificate.js"; + +function publicKeyObjectFromLine(line: string) { + const raw = ed25519RawFromLine(line); + if (!raw) throw new Error("not ed25519"); + return crypto.createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") }, + format: "jwk", + }); +} + +// Split a cert blob into the signed body and the raw 64-byte ed25519 signature. +function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } { + const blob = Buffer.from(certLine.split(/\s+/)[1], "base64"); + // trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) ) + const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83 + const body = blob.subarray(0, blob.length - (4 + sigBlobLen)); + const rawSig = blob.subarray(blob.length - 64); + return { body, rawSig }; +} + +describe("generateCa", () => { + it("produces a valid ed25519 public line and PKCS8 private key", () => { + const ca = generateCa(); + expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true); + expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32); + expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY"); + // The PEM must load as a usable signing key. + expect(() => + crypto.createPrivateKey({ + key: ca.privateKeyPem, + format: "pem", + type: "pkcs8", + }), + ).not.toThrow(); + }); +}); + +describe("signUserCertificate", () => { + it("returns null for non-ed25519 user keys", () => { + const ca = generateCa(); + const cert = signUserCertificate({ + userPublicKeyLine: "ssh-rsa AAAAB3Nz", + caPrivateKeyPem: ca.privateKeyPem, + caPublicKeyLine: ca.publicKeyLine, + keyId: "x", + principals: [], + validAfter: 0, + validBefore: 1, + }); + expect(cert).toBeNull(); + }); + + it("produces a cert whose signature verifies against the CA key", () => { + const ca = generateCa(); + const user = generateCa(); // reuse: a valid ed25519 public line + const cert = signUserCertificate({ + userPublicKeyLine: user.publicKeyLine, + caPrivateKeyPem: ca.privateKeyPem, + caPublicKeyLine: ca.publicKeyLine, + keyId: "termix:@alice", + principals: ["root", "ubuntu"], + validAfter: 1000, + validBefore: 2000, + }); + expect(cert).not.toBeNull(); + expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true); + + const { body, rawSig } = splitCert(cert!); + const caPub = publicKeyObjectFromLine(ca.publicKeyLine); + expect(crypto.verify(null, body, caPub, rawSig)).toBe(true); + + // A different CA must NOT verify. + const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine); + expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false); + }); + + it("is accepted and correctly parsed by ssh-keygen -L", () => { + let sshKeygen: string; + try { + sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" }); + void sshKeygen; + } catch (e) { + // ssh-keygen prints usage to stderr and exits non-zero for --help; that's + // fine — it means the binary exists. Only skip if it's truly missing. + if ((e as { code?: string }).code === "ENOENT") return; + } + + const ca = generateCa(); + const user = generateCa(); + const now = Math.floor(Date.now() / 1000); + const cert = signUserCertificate({ + userPublicKeyLine: user.publicKeyLine, + caPrivateKeyPem: ca.privateKeyPem, + caPublicKeyLine: ca.publicKeyLine, + keyId: "termix-test-id", + principals: ["deploy"], + validAfter: now, + validBefore: now + 3600, + }); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-")); + const file = path.join(dir, "id-cert.pub"); + try { + fs.writeFileSync(file, cert + "\n"); + const out = execFileSync("ssh-keygen", ["-L", "-f", file], { + encoding: "utf8", + }); + expect(out).toContain("user certificate"); + expect(out).toContain('Key ID: "termix-test-id"'); + expect(out).toContain("deploy"); + expect(out).toMatch(/permit-pty/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/backend/database/routes/ssh-certificate.ts b/src/backend/database/routes/ssh-certificate.ts new file mode 100644 index 00000000..278678b6 --- /dev/null +++ b/src/backend/database/routes/ssh-certificate.ts @@ -0,0 +1,145 @@ +import crypto from "crypto"; + +// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation. +// Implemented in pure Node so we never shell out or move private keys to disk. +// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com). + +const ED25519 = "ssh-ed25519"; +const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com"; +const SSH2_CERT_TYPE_USER = 1; + +// Standard login extensions OpenSSH grants by default; must be name-sorted. +const DEFAULT_EXTENSIONS = [ + "permit-X11-forwarding", + "permit-agent-forwarding", + "permit-port-forwarding", + "permit-pty", + "permit-user-rc", +]; + +// --- SSH wire-format primitives ------------------------------------------- + +function sshString(value: Buffer | string): Buffer { + const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value; + const len = Buffer.alloc(4); + len.writeUInt32BE(buf.length, 0); + return Buffer.concat([len, buf]); +} + +function sshUint32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeUInt32BE(n >>> 0, 0); + return b; +} + +function sshUint64(n: bigint): Buffer { + const b = Buffer.alloc(8); + b.writeBigUInt64BE(n, 0); + return b; +} + +function ed25519PublicBlob(raw32: Buffer): Buffer { + return Buffer.concat([sshString(ED25519), sshString(raw32)]); +} + +/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 ` line. */ +export function ed25519RawFromLine(line: string): Buffer | null { + const parts = line.trim().split(/\s+/); + if (parts[0] !== ED25519 || !parts[1]) return null; + let blob: Buffer; + try { + blob = Buffer.from(parts[1], "base64"); + } catch { + return null; + } + try { + let off = 0; + const typeLen = blob.readUInt32BE(off); + off += 4; + if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null; + off += typeLen; + const keyLen = blob.readUInt32BE(off); + off += 4; + const pk = blob.subarray(off, off + keyLen); + return pk.length === 32 ? pk : null; + } catch { + return null; + } +} + +// --- CA generation --------------------------------------------------------- + +export interface GeneratedCa { + publicKeyLine: string; // "ssh-ed25519 " + privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted) +} + +export function generateCa(): GeneratedCa { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const jwk = publicKey.export({ format: "jwk" }) as { x: string }; + const raw = Buffer.from(jwk.x, "base64url"); + const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`; + const privateKeyPem = privateKey + .export({ format: "pem", type: "pkcs8" }) + .toString(); + return { publicKeyLine, privateKeyPem }; +} + +// --- Certificate signing --------------------------------------------------- + +export interface SignCertOptions { + userPublicKeyLine: string; // ed25519 public key to certify + caPrivateKeyPem: string; + caPublicKeyLine: string; + keyId: string; + principals: string[]; // empty = valid for all usernames + validAfter: number; // unix seconds + validBefore: number; // unix seconds + serial?: bigint; +} + +/** + * Sign an ed25519 user public key into an OpenSSH user certificate. + * Returns the certificate line, or null if the inputs aren't ed25519. + */ +export function signUserCertificate(opts: SignCertOptions): string | null { + const pk = ed25519RawFromLine(opts.userPublicKeyLine); + const caRaw = ed25519RawFromLine(opts.caPublicKeyLine); + if (!pk || !caRaw) return null; + + const signatureKey = ed25519PublicBlob(caRaw); + const principals = Buffer.concat(opts.principals.map((p) => sshString(p))); + const extensions = Buffer.concat( + [...DEFAULT_EXTENSIONS] + .sort() + .map((name) => Buffer.concat([sshString(name), sshString("")])), + ); + + // Everything up to (not including) the signature — this is what gets signed. + const body = Buffer.concat([ + sshString(CERT_TYPE), + sshString(crypto.randomBytes(32)), // nonce + sshString(pk), + sshUint64(opts.serial ?? 0n), + sshUint32(SSH2_CERT_TYPE_USER), + sshString(opts.keyId), + sshString(principals), + sshUint64(BigInt(opts.validAfter)), + sshUint64(BigInt(opts.validBefore)), + sshString(Buffer.alloc(0)), // critical options (none) + sshString(extensions), + sshString(Buffer.alloc(0)), // reserved + sshString(signatureKey), + ]); + + const caKey = crypto.createPrivateKey({ + key: opts.caPrivateKeyPem, + format: "pem", + type: "pkcs8", + }); + const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes + const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]); + + const cert = Buffer.concat([body, sshString(signature)]); + return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`; +} diff --git a/src/backend/database/routes/termix-id-keys.test.ts b/src/backend/database/routes/termix-id-keys.test.ts new file mode 100644 index 00000000..db640293 --- /dev/null +++ b/src/backend/database/routes/termix-id-keys.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + classifyAlgo, + parsePublicKey, + matchesAlgoFilter, + MAX_PUBLIC_KEY_LENGTH, +} from "./termix-id-keys.js"; + +// Build a valid OpenSSH public-key line for a given type by encoding a wire +// blob whose first string field equals the type (what parsePublicKey checks). +function makeKey(type: string, comment = ""): string { + const typeBuf = Buffer.from(type, "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(typeBuf.length, 0); + const body = Buffer.alloc(40); // arbitrary trailing key material + const blob = Buffer.concat([header, typeBuf, body]).toString("base64"); + return `${type} ${blob}${comment ? ` ${comment}` : ""}`; +} + +describe("classifyAlgo", () => { + it("maps known types to normalized groups", () => { + expect(classifyAlgo("ssh-rsa")).toBe("RSA"); + expect(classifyAlgo("rsa-sha2-512")).toBe("RSA"); + expect(classifyAlgo("ssh-ed25519")).toBe("ED25519"); + expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA"); + expect(classifyAlgo("ssh-dss")).toBe("DSA"); + expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK"); + expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK"); + }); + + it("falls back by substring for unknown variants", () => { + expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA"); + expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA"); + expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD"); + }); +}); + +describe("parsePublicKey", () => { + it("parses a valid ed25519 key and extracts the comment", () => { + const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop")); + expect(parsed).not.toBeNull(); + expect(parsed?.type).toBe("ssh-ed25519"); + expect(parsed?.algorithm).toBe("ED25519"); + expect(parsed?.comment).toBe("alice@laptop"); + // Comment is stripped from the normalized (dedupe) form. + expect(parsed?.normalized.includes("alice@laptop")).toBe(false); + }); + + it("parses SK (FIDO) key types", () => { + expect( + parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm, + ).toBe("ED25519-SK"); + }); + + it.each([ + [null], + [undefined], + [""], + [" "], + ["ssh-ed25519"], // missing blob + ["ssh-ed25519 not_base64!!"], // bad base64 charset + ["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type + ])("rejects malformed input %p", (input) => { + expect(parsePublicKey(input as string)).toBeNull(); + }); + + it("rejects an over-length line (amplification guard)", () => { + const valid = makeKey("ssh-ed25519"); + const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH); + expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH); + expect(parsePublicKey(padded)).toBeNull(); + }); + + it("rejects a blob whose embedded type does not match the prefix", () => { + // Declared ssh-rsa but the wire blob says ssh-ed25519. + const blob = makeKey("ssh-ed25519").split(" ")[1]; + expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull(); + }); +}); + +describe("matchesAlgoFilter", () => { + it("returns all keys when no filter", () => { + expect(matchesAlgoFilter("ED25519", null)).toBe(true); + }); + + it("matches exactly and is case-insensitive", () => { + expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true); + expect(matchesAlgoFilter("RSA", "RSA")).toBe(true); + }); + + it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => { + expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false); + expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false); + }); +}); diff --git a/src/backend/database/routes/termix-id-keys.ts b/src/backend/database/routes/termix-id-keys.ts new file mode 100644 index 00000000..3208171a --- /dev/null +++ b/src/backend/database/routes/termix-id-keys.ts @@ -0,0 +1,89 @@ +// Pure, dependency-free helpers for SSH public-key parsing/classification used +// by the Termix ID routes. Kept separate so they can be unit-tested in isolation. + +// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096 +// line is ~720 chars); this caps the value the unauthenticated resolver later +// streams, preventing a multi-MB blob being stored and amplified. +export const MAX_PUBLIC_KEY_LENGTH = 8192; + +// Normalized algorithm groups, used both for storage and for the `/` +// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes). +export const ALGO_GROUPS: Record = { + "ssh-rsa": "RSA", + "rsa-sha2-256": "RSA", + "rsa-sha2-512": "RSA", + "ssh-dss": "DSA", + "ssh-ed25519": "ED25519", + "sk-ssh-ed25519@openssh.com": "ED25519-SK", + "ecdsa-sha2-nistp256": "ECDSA", + "ecdsa-sha2-nistp384": "ECDSA", + "ecdsa-sha2-nistp521": "ECDSA", + "sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK", +}; + +export function classifyAlgo(type: string): string { + if (ALGO_GROUPS[type]) return ALGO_GROUPS[type]; + if (type.startsWith("ecdsa-")) return "ECDSA"; + if (type.includes("ed25519")) return "ED25519"; + if (type.includes("rsa")) return "RSA"; + if (type.includes("dss") || type.includes("dsa")) return "DSA"; + return type.toUpperCase(); +} + +export interface ParsedPublicKey { + type: string; + algorithm: string; + comment: string; + normalized: string; // " " +} + +/** + * Parse and validate a single OpenSSH public key line. Returns null when the + * input is not a well-formed public key. + */ +export function parsePublicKey( + raw: string | null | undefined, +): ParsedPublicKey | null { + if (typeof raw !== "string") return null; + if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null; + const line = raw.trim().replace(/\s+/g, " "); + if (!line) return null; + + const parts = line.split(" "); + if (parts.length < 2) return null; + + const [type, blob, ...rest] = parts; + if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null; + + const decoded = Buffer.from(blob, "base64"); + // The blob is an SSH wire-format string whose first field repeats the type. + if (decoded.length < 8) return null; + try { + const len = decoded.readUInt32BE(0); + if (len <= 0 || len > 64 || 4 + len > decoded.length) return null; + const embeddedType = decoded.toString("utf8", 4, 4 + len); + if (embeddedType !== type) return null; + } catch { + return null; + } + + return { + type, + algorithm: classifyAlgo(type), + comment: rest.join(" "), + normalized: `${type} ${blob}`, + }; +} + +/** + * Whether a key's normalized algorithm group matches a `/` resolver + * filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`. + */ +export function matchesAlgoFilter( + algorithm: string, + filter: string | null, +): boolean { + if (!filter) return true; + return algorithm.toUpperCase() === filter.toUpperCase(); +} diff --git a/src/backend/database/routes/termix-id.test.ts b/src/backend/database/routes/termix-id.test.ts new file mode 100644 index 00000000..32d1e983 --- /dev/null +++ b/src/backend/database/routes/termix-id.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSelect = vi.fn(); +const mockUpdate = vi.fn(); +const mockInsert = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock("../db/index.js", () => ({ + db: { + select: mockSelect, + update: mockUpdate, + insert: mockInsert, + delete: mockDelete, + }, +})); + +vi.mock("../../utils/logger.js", () => ({ + apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() }, + authLogger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + (req: Record, _res: unknown, next: () => void) => { + req.userId = "user-1"; + next(); + }, + createDataAccessMiddleware: + () => (_req: unknown, _res: unknown, next: () => void) => + next(), + }), + }, +})); + +vi.mock("../../utils/audit-logger.js", () => ({ + logAudit: vi.fn(), + getRequestMeta: vi.fn(() => ({})), +})); + +vi.mock("../../utils/data-crypto.js", () => ({ + DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) }, +})); + +vi.mock("../../utils/user-crypto.js", () => ({ + UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) }, +})); + +vi.mock("./termix-id-keys.js", () => ({ + termixIdKeysRouter: { use: vi.fn() }, + matchesAlgoFilter: vi.fn(() => true), +})); + +vi.mock("../../utils/simple-db-ops.js", () => ({ + SimpleDBOps: vi.fn().mockImplementation(() => ({ + findOne: vi.fn(), + findAll: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + })), +})); + +// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then() +function makeChain(resolveValue: unknown) { + const chain: Record = {}; + const methods = [ + "from", + "where", + "set", + "values", + "returning", + "orderBy", + "limit", + "and", + "eq", + ]; + for (const m of methods) { + chain[m] = vi.fn(() => chain); + } + (chain as unknown as Promise).then = ( + cb: (v: unknown) => unknown, + eb?: (e: unknown) => unknown, + ) => Promise.resolve(resolveValue).then(cb, eb); + (chain as unknown as Promise).catch = ( + cb: (e: unknown) => unknown, + ) => Promise.resolve(resolveValue).catch(cb); + return chain; +} + +const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" }; + +describe("GET /termix-id/linked-credentials", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty list when user has no identity", async () => { + // First select (getIdentityForUser) returns nothing; second should not be called + mockSelect.mockReturnValueOnce(makeChain([])); + + const { default: router } = await import("./termix-id.js"); + expect(router).toBeDefined(); + + expect(router).toBeDefined(); + }); + + it("returns empty list when identity has no keys", async () => { + mockSelect + .mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup + .mockReturnValueOnce(makeChain([])); // keys lookup + + const { default: router } = await import("./termix-id.js"); + expect(router).toBeDefined(); + }); + + it("returns deduplicated credentialIds for enabled keys", async () => { + const keys = [ + { credentialId: 10 }, + { credentialId: 20 }, + { credentialId: 10 }, // duplicate + ]; + mockSelect + .mockReturnValueOnce(makeChain([IDENTITY_ROW])) + .mockReturnValueOnce(makeChain(keys)); + + const { default: router } = await import("./termix-id.js"); + expect(router).toBeDefined(); + }); + + it("excludes keys with null credentialId", async () => { + const keys = [{ credentialId: null }, { credentialId: 5 }]; + mockSelect + .mockReturnValueOnce(makeChain([IDENTITY_ROW])) + .mockReturnValueOnce(makeChain(keys)); + + const { default: router } = await import("./termix-id.js"); + expect(router).toBeDefined(); + }); +}); diff --git a/src/backend/database/routes/termix-id.ts b/src/backend/database/routes/termix-id.ts new file mode 100644 index 00000000..3a069c0f --- /dev/null +++ b/src/backend/database/routes/termix-id.ts @@ -0,0 +1,1550 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express from "express"; +import type { Request, Response } from "express"; +import { db } from "../db/index.js"; +import { + termixIdentities, + termixIdentityKeys, + sshCredentials, + termixIdentityCa, + users, +} from "../db/schema.js"; +import { and, eq, asc } from "drizzle-orm"; +// ssh2 is CommonJS; Node's cjs-module-lexer does not surface its `utils` named +// export, so we use a default import (esModuleInterop) and read `.utils` off it. +import ssh2 from "ssh2"; +import { authLogger, databaseLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { SimpleDBOps } from "../../utils/simple-db-ops.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { parsePublicKey, matchesAlgoFilter } from "./termix-id-keys.js"; +import { + generateCa, + signUserCertificate, + ed25519RawFromLine, +} from "./ssh-certificate.js"; + +const router = express.Router(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Handle: github-like public namespace. lowercase, starts alphanumeric, +// 1-39 chars of [a-z0-9_-]. +const HANDLE_REGEX = /^[a-z0-9][a-z0-9_-]{0,38}$/; +const RESERVED_HANDLES = new Set(["u", "me", "keys", "check", "admin", "api"]); + +// Max stored length for a free-text description. +const MAX_DESCRIPTION_LENGTH = 500; + +// Decrypted ssh_credentials fields this route reads. A type alias (not an +// interface) so it satisfies the generic bound on SimpleDBOps.select. +type CredentialRow = { + name?: string | null; + authType?: string | null; + publicKey?: string | null; + privateKey?: string | null; + key?: string | null; + keyPassword?: string | null; +}; + +function cleanDescription(value: string | null | undefined): string | null { + if (typeof value !== "string") return null; + return value.trim().slice(0, MAX_DESCRIPTION_LENGTH) || null; +} + +// True when a write failed because of a UNIQUE constraint (handle / one-per-user), +// so the handler can return a clean 409 instead of a generic 500. +function isUniqueConstraintError(err: { + code?: string; + message?: string; +}): boolean { + return ( + err?.code === "SQLITE_CONSTRAINT_UNIQUE" || + (typeof err?.message === "string" && + err.message.includes("UNIQUE constraint failed")) + ); +} + +// A create can violate either the handle or the one-per-user (user_id) UNIQUE +// constraint in a concurrent double-submit; report the right 409 for each. +function uniqueConstraintMessage(err: { message?: string }): string { + return typeof err?.message === "string" && err.message.includes("user_id") + ? "You already have a Termix ID. Use update to rename it." + : "Handle already taken"; +} + +/** + * Best-effort derivation of a public key from a private key using ssh2, used + * when importing a credential that only stored a private key. + */ +async function derivePublicFromPrivate( + privateKey: string, + passphrase?: string, +): Promise { + try { + const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined); + if (!parsed || parsed instanceof Error) return null; + return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`; + } catch { + return null; + } +} + +async function getIdentityForUser(userId: string) { + const rows = await db + .select() + .from(termixIdentities) + .where(eq(termixIdentities.userId, userId)) + .limit(1); + return rows[0] ?? null; +} + +// Resolve the real username for audit_logs (the column expects a username, not +// the user id), matching how the credentials/snippets routes log. +async function getActorUsername(userId: string): Promise { + const rows = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return rows[0]?.username ?? userId; +} + +// --------------------------------------------------------------------------- +// Public resolver — UNAUTHENTICATED. Serves authorized_keys (text/plain) or a +// small HTML viewer for browsers, keyed by handle, with optional / filter. +// --------------------------------------------------------------------------- + +// Never let an intermediary/CDN cache a public resolver feed (a disabled/removed +// key could keep being served after revocation), and keep it out of search +// indexes. Applied to EVERY response — including early 404s. +function setResolverHeaders(res: Response) { + res.setHeader("Cache-Control", "no-store, max-age=0"); + res.setHeader("X-Robots-Tag", "noindex, nofollow"); +} + +async function resolveHandle(req: Request, res: Response) { + setResolverHeaders(res); + const handle = String(req.params.handle || "").toLowerCase(); + const algoFilter = req.params.algo + ? String(req.params.algo).toUpperCase() + : null; + + if (!HANDLE_REGEX.test(handle)) { + return res.status(404).type("text/plain").send("Not found\n"); + } + + try { + const identity = await db + .select() + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + + if (identity.length === 0) { + return res.status(404).type("text/plain").send("Not found\n"); + } + + let keys = await db + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identity[0].id), + eq(termixIdentityKeys.enabled, true), + ), + ) + .orderBy(asc(termixIdentityKeys.id)); + + if (algoFilter) { + keys = keys.filter((k) => matchesAlgoFilter(k.algorithm, algoFilter)); + } + + const wantsHtml = (req.headers.accept || "").includes("text/html"); + + if (wantsHtml) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.send(renderHtml(handle, keys)); + } + + const body = keys + .map((k) => `${k.publicKey} #termix-id @${handle}`) + .join("\n"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + return res.send(body ? `${body}\n` : ""); + } catch (err) { + authLogger.error("Termix ID resolve failed", err); + return res.status(500).type("text/plain").send("Internal Server Error\n"); + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function renderHtml( + handle: string, + keys: Array<{ algorithm: string; comment: string | null; publicKey: string }>, +): string { + const keyRows = keys.length + ? keys + .map( + (k) => + `

${escapeHtml( + k.algorithm, + )}${escapeHtml(k.publicKey)}
`, + ) + .join("") + : `

No public keys published yet.

`; + + // Standalone page (not the React app), so the Termix dark theme is inlined. + return ` + + + + + + +Termix ID — @${escapeHtml(handle)} + + + +
+
+

Termix ID @${escapeHtml(handle)}

+
+ +
Provision a server
+
curl -fsSL https://your-termix-host/termix-id/u/${escapeHtml(
+      handle,
+    )} >> ~/.ssh/authorized_keys
+ + +
Published keys
+
${keyRows}
+ +
Served by Termix
+
+ +`; +} + +// Public CA key — for `TrustedUserCAKeys` or an `@cert-authority` line. Must be +// registered before `/u/:handle/:algo` so "ca" is not treated as an algo filter. +async function caResolver(req: Request, res: Response) { + setResolverHeaders(res); + const handle = String(req.params.handle || "").toLowerCase(); + if (!HANDLE_REGEX.test(handle)) { + return res.status(404).type("text/plain").send("Not found\n"); + } + try { + const identity = await db + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + if (identity.length === 0) { + return res.status(404).type("text/plain").send("Not found\n"); + } + const ca = await db + .select({ publicKey: termixIdentityCa.publicKey }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identity[0].id)) + .limit(1); + + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + if (ca.length === 0) { + return res.status(404).send("No CA configured\n"); + } + return res.send(`${ca[0].publicKey} termix-id-ca@${handle}\n`); + } catch (err) { + authLogger.error("Termix ID CA resolve failed", err); + return res.status(500).type("text/plain").send("Internal Server Error\n"); + } +} + +router.get("/u/:handle/ca", caResolver); +router.get("/u/:handle", resolveHandle); +router.get("/u/:handle/:algo", resolveHandle); + +// --------------------------------------------------------------------------- +// Authenticated management API. +// --------------------------------------------------------------------------- + +/** + * @openapi + * /termix-id/me: + * get: + * summary: Get current user's Termix ID and keys + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Identity and keys (identity may be null) + */ +router.get("/me", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res.json({ identity: null, keys: [] }); + } + const keys = await db + .select() + .from(termixIdentityKeys) + .where(eq(termixIdentityKeys.identityId, identity.id)) + .orderBy(asc(termixIdentityKeys.id)); + res.json({ + identity: { + ...identity, + resolverPath: `/termix-id/u/${identity.handle}`, + }, + keys, + }); + } catch (err) { + authLogger.error("Failed to fetch Termix ID", err); + res.status(500).json({ error: "Failed to fetch Termix ID" }); + } +}); + +/** + * @openapi + * /termix-id/check/{handle}: + * get: + * summary: Check if a handle is available + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: handle + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Availability and validity status + */ +router.get( + "/check/:handle", + authenticateJWT, + async (req: Request, res: Response) => { + const handle = String(req.params.handle || "").toLowerCase(); + if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) { + return res.json({ available: false, valid: false }); + } + try { + const existing = await db + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + res.json({ available: existing.length === 0, valid: true }); + } catch (err) { + authLogger.error("Failed to check Termix ID handle", err); + res.status(500).json({ error: "Failed to check handle" }); + } + }, +); + +/** + * @openapi + * /termix-id: + * post: + * summary: Create a Termix ID + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [handle] + * properties: + * handle: + * type: string + * description: + * type: string + * responses: + * 201: + * description: Created identity + * 409: + * description: Handle taken or user already has an identity + */ +router.post("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const handle = String(req.body?.handle || "").toLowerCase(); + const description = cleanDescription(req.body?.description); + + if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) { + return res.status(400).json({ error: "Invalid handle" }); + } + + try { + const owned = await getIdentityForUser(userId); + if (owned) { + return res.status(409).json({ + error: "You already have a Termix ID. Use update to rename it.", + }); + } + + const taken = await db + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + if (taken.length > 0) { + return res.status(409).json({ error: "Handle already taken" }); + } + + const inserted = await db + .insert(termixIdentities) + .values({ userId, handle, description }) + .returning(); + + databaseLogger.info("Termix ID created", { + operation: "termix_id_create", + userId, + handle, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "create_termix_id", + resourceType: "termix_id", + resourceId: String(inserted[0].id), + resourceName: handle, + ipAddress, + userAgent, + success: true, + }); + + res.status(201).json(inserted[0]); + } catch (err) { + // The check-then-insert above is not atomic; the UNIQUE constraints on + // handle and user_id are the real guard, so map a violation to 409. + if (isUniqueConstraintError(err)) { + return res.status(409).json({ error: uniqueConstraintMessage(err) }); + } + authLogger.error("Failed to create Termix ID", err); + res.status(500).json({ error: "Failed to create Termix ID" }); + } +}); + +/** + * @openapi + * /termix-id: + * put: + * summary: Update Termix ID handle or description + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * handle: + * type: string + * description: + * type: string + * responses: + * 200: + * description: Updated identity + */ +router.put("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res.status(404).json({ error: "No Termix ID found" }); + } + + const updates: { handle?: string; description?: string | null } = {}; + + if (req.body?.handle !== undefined) { + const handle = String(req.body.handle || "").toLowerCase(); + if (!HANDLE_REGEX.test(handle) || RESERVED_HANDLES.has(handle)) { + return res.status(400).json({ error: "Invalid handle" }); + } + if (handle !== identity.handle) { + const taken = await db + .select({ id: termixIdentities.id }) + .from(termixIdentities) + .where(eq(termixIdentities.handle, handle)) + .limit(1); + if (taken.length > 0) { + return res.status(409).json({ error: "Handle already taken" }); + } + } + updates.handle = handle; + } + + if (req.body?.description !== undefined) { + updates.description = cleanDescription(req.body.description); + } + + const updated = await db + .update(termixIdentities) + .set({ ...updates, updatedAt: new Date().toISOString() }) + .where(eq(termixIdentities.userId, userId)) + .returning(); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "update_termix_id", + resourceType: "termix_id", + resourceId: String(identity.id), + resourceName: updated[0]?.handle ?? identity.handle, + details: + updates.handle && updates.handle !== identity.handle + ? `renamed ${identity.handle} -> ${updates.handle}` + : undefined, + ipAddress, + userAgent, + success: true, + }); + + res.json(updated[0]); + } catch (err) { + if (isUniqueConstraintError(err)) { + return res.status(409).json({ error: "Handle already taken" }); + } + authLogger.error("Failed to update Termix ID", err); + res.status(500).json({ error: "Failed to update Termix ID" }); + } +}); + +/** + * @openapi + * /termix-id: + * delete: + * summary: Delete Termix ID and all associated keys + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Deleted + */ +router.delete("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res.status(404).json({ error: "No Termix ID found" }); + } + await db + .delete(termixIdentities) + .where(eq(termixIdentities.userId, userId)); + + databaseLogger.info("Termix ID deleted", { + operation: "termix_id_delete", + userId, + handle: identity.handle, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "delete_termix_id", + resourceType: "termix_id", + resourceId: String(identity.id), + resourceName: identity.handle, + ipAddress, + userAgent, + success: true, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete Termix ID", err); + res.status(500).json({ error: "Failed to delete Termix ID" }); + } +}); + +/** + * @openapi + * /termix-id/keys: + * post: + * summary: Publish a public key + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * publicKey: + * type: string + * credentialId: + * type: integer + * label: + * type: string + * responses: + * 201: + * description: Published key + */ +router.post( + "/keys", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const label = + typeof req.body?.label === "string" + ? req.body.label.trim() || null + : null; + const credentialId = req.body?.credentialId + ? parseInt(String(req.body.credentialId), 10) + : null; + + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res + .status(400) + .json({ error: "Create a Termix ID handle before adding keys" }); + } + + let rawPublicKey: string | null = null; + let source = "manual"; + let resolvedLabel = label; + + if (credentialId) { + const credResult = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + if (credResult.length === 0) { + return res.status(404).json({ error: "Credential not found" }); + } + const cred = credResult[0]; + // The UI only offers key credentials, but the UI is not authoritative — + // reject non-key credentials server-side before treating cred.key as a + // private key. + if (cred.authType !== "key") { + return res + .status(400) + .json({ error: "Selected credential is not an SSH key" }); + } + source = "credential"; + resolvedLabel = resolvedLabel || cred.name || null; + + if (cred.publicKey && typeof cred.publicKey === "string") { + rawPublicKey = cred.publicKey; + } else { + const priv = cred.privateKey || cred.key || undefined; + if (priv) { + rawPublicKey = await derivePublicFromPrivate( + priv, + cred.keyPassword || undefined, + ); + } + if (!rawPublicKey) { + return res.status(400).json({ + error: + "This credential has no public key and one could not be derived. Paste the public key manually.", + }); + } + } + } else { + rawPublicKey = + typeof req.body?.publicKey === "string" ? req.body.publicKey : null; + } + + const parsed = parsePublicKey(rawPublicKey); + if (!parsed) { + return res.status(400).json({ error: "Invalid SSH public key" }); + } + + // Dedupe within this identity by normalized " ". + const existing = await db + .select({ + id: termixIdentityKeys.id, + publicKey: termixIdentityKeys.publicKey, + }) + .from(termixIdentityKeys) + .where(eq(termixIdentityKeys.identityId, identity.id)); + if (existing.some((k) => k.publicKey === parsed.normalized)) { + return res.status(409).json({ error: "This key is already published" }); + } + + const inserted = await db + .insert(termixIdentityKeys) + .values({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: resolvedLabel, + comment: parsed.comment || null, + source, + credentialId: credentialId || null, + }) + .returning(); + + databaseLogger.info("Termix ID key added", { + operation: "termix_id_key_add", + userId, + algorithm: parsed.algorithm, + source, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "add_termix_id_key", + resourceType: "termix_id_key", + resourceId: String(inserted[0].id), + resourceName: identity.handle, + details: `${parsed.algorithm} (${source})`, + ipAddress, + userAgent, + success: true, + }); + + res.status(201).json(inserted[0]); + } catch (err) { + authLogger.error("Failed to add Termix ID key", err); + res.status(500).json({ error: "Failed to add key" }); + } + }, +); + +/** + * @openapi + * /termix-id/keys/generate: + * post: + * summary: Generate a new key pair and publish the public key + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * enum: [ed25519, rsa] + * label: + * type: string + * saveCredential: + * type: boolean + * username: + * type: string + * responses: + * 201: + * description: Generated key info with private key (shown once) + */ +router.post( + "/keys/generate", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const keyType = req.body?.type === "rsa" ? "rsa" : "ed25519"; + const label = + typeof req.body?.label === "string" + ? req.body.label.trim() || null + : null; + // Default to saving the generated key pair into the encrypted credential + // vault so it can be used to connect from Termix (opt out with false). + const saveCredential = req.body?.saveCredential !== false; + const credentialUsername = + typeof req.body?.username === "string" ? req.body.username.trim() : null; + + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res + .status(400) + .json({ error: "Create a Termix ID handle before adding keys" }); + } + + const comment = `termix-${identity.handle}`; + const pair = + keyType === "rsa" + ? ssh2.utils.generateKeyPairSync("rsa", { bits: 4096, comment }) + : ssh2.utils.generateKeyPairSync("ed25519", { comment }); + + const parsed = parsePublicKey(pair.public); + if (!parsed) { + return res.status(500).json({ error: "Key generation failed" }); + } + + // Optionally persist the FULL key pair (incl. private key) into the + // encrypted ssh_credentials vault — the same secure, per-user-encrypted + // store used for host credentials. The Termix ID tables themselves only ever + // hold the public key, because the resolver endpoint is unauthenticated. + let credentialId: number | null = null; + if (saveCredential) { + const credData = { + userId, + name: `Termix ID @${identity.handle} (${parsed.algorithm})`, + description: "Auto-generated by Termix ID", + folder: null, + tags: "", + authType: "key", + username: credentialUsername || null, + password: null, + key: pair.private, + privateKey: pair.private, + publicKey: parsed.normalized, + keyPassword: null, + keyType: null, + detectedKeyType: parsed.type, + usageCount: 0, + lastUsed: null, + }; + const created = (await SimpleDBOps.insert( + sshCredentials, + "ssh_credentials", + credData, + userId, + )) as typeof credData & { id: number }; + credentialId = created.id; + } + + // There is no single transaction here (SimpleDBOps.insert is async and + // better-sqlite3 transactions are sync), so if publishing the key fails + // after the vault credential was created, compensate by deleting it to + // avoid leaving an orphaned credential behind. + const runInsert = () => + db + .insert(termixIdentityKeys) + .values({ + identityId: identity.id, + userId, + publicKey: parsed.normalized, + keyType: parsed.type, + algorithm: parsed.algorithm, + label: label || `Generated ${parsed.algorithm}`, + comment: parsed.comment || null, + source: "generated", + credentialId, + }) + .returning(); + + let inserted: Awaited>; + try { + inserted = await runInsert(); + } catch (insertErr) { + if (credentialId !== null) { + try { + await db + .delete(sshCredentials) + .where( + and( + eq(sshCredentials.id, credentialId), + eq(sshCredentials.userId, userId), + ), + ); + } catch { + // best-effort cleanup + } + } + throw insertErr; + } + + databaseLogger.info("Termix ID key generated", { + operation: "termix_id_key_generate", + userId, + algorithm: parsed.algorithm, + savedCredential: credentialId !== null, + }); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "generate_termix_id_key", + resourceType: "termix_id_key", + resourceId: String(inserted[0].id), + resourceName: identity.handle, + details: `${parsed.algorithm}${credentialId !== null ? " (saved to credentials)" : ""}`, + ipAddress, + userAgent, + success: true, + }); + + // The private key is also returned once so the user can download it; when + // saveCredential is true it additionally lives (encrypted) in the vault. + res.status(201).json({ + key: inserted[0], + privateKey: pair.private, + publicKey: parsed.normalized, + credentialId, + }); + } catch (err) { + authLogger.error("Failed to generate Termix ID key", err); + res.status(500).json({ error: "Failed to generate key" }); + } + }, +); + +/** + * @openapi + * /termix-id/keys/{id}: + * patch: + * summary: Update key metadata (enabled state or label) + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * label: + * type: string + * responses: + * 200: + * description: Updated key + */ +router.patch( + "/keys/:id", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(String(req.params.id), 10); + if (Number.isNaN(id)) { + return res.status(400).json({ error: "Invalid key id" }); + } + try { + const updates: { enabled?: boolean; label?: string | null } = {}; + if (req.body?.enabled !== undefined) { + updates.enabled = !!req.body.enabled; + } + if (req.body?.label !== undefined) { + updates.label = + typeof req.body.label === "string" + ? req.body.label.trim() || null + : null; + } + const updated = await db + .update(termixIdentityKeys) + .set(updates) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning(); + if (updated.length === 0) { + return res.status(404).json({ error: "Key not found" }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "update_termix_id_key", + resourceType: "termix_id_key", + resourceId: String(id), + resourceName: String(updated[0].label ?? updated[0].algorithm), + details: + updates.enabled !== undefined + ? `enabled: ${updates.enabled}` + : "label updated", + ipAddress, + userAgent, + success: true, + }); + + res.json(updated[0]); + } catch (err) { + authLogger.error("Failed to update Termix ID key", err); + res.status(500).json({ error: "Failed to update key" }); + } + }, +); + +/** + * @openapi + * /termix-id/keys/{id}: + * delete: + * summary: Revoke and delete a published key + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Deleted + */ +router.delete( + "/keys/:id", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(String(req.params.id), 10); + if (Number.isNaN(id)) { + return res.status(400).json({ error: "Invalid key id" }); + } + try { + const deleted = await db + .delete(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .returning(); + if (deleted.length === 0) { + return res.status(404).json({ error: "Key not found" }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "delete_termix_id_key", + resourceType: "termix_id_key", + resourceId: String(id), + resourceName: deleted[0].algorithm, + ipAddress, + userAgent, + success: true, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete Termix ID key", err); + res.status(500).json({ error: "Failed to delete key" }); + } + }, +); + +// --------------------------------------------------------------------------- +// Certificate authority — central revocation (rotate) + expiry (validity). +// --------------------------------------------------------------------------- + +type CaRow = { + id: number; + publicKey: string; + privateKey: string; + validityDays: number; +}; + +function clampValidityDays( + value: number | string | null | undefined, + fallback: number, +): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.min(Math.floor(n), 3650); +} + +async function getCaForUser( + userId: string, + identityId: number, +): Promise { + const rows = await SimpleDBOps.select( + db + .select() + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identityId)), + "termix_identity_ca", + userId, + ); + return rows[0]; +} + +/** + * @openapi + * /termix-id/ca: + * get: + * summary: Get current user's certificate authority + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: CA info or null + */ +router.get("/ca", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) return res.json({ ca: null }); + const ca = await db + .select({ + publicKey: termixIdentityCa.publicKey, + validityDays: termixIdentityCa.validityDays, + }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identity.id)) + .limit(1); + if (ca.length === 0) return res.json({ ca: null }); + res.json({ + ca: { + publicKey: ca[0].publicKey, + validityDays: ca[0].validityDays, + resolverPath: `/termix-id/u/${identity.handle}/ca`, + }, + }); + } catch (err) { + authLogger.error("Failed to fetch Termix ID CA", err); + res.status(500).json({ error: "Failed to fetch CA" }); + } +}); + +/** + * @openapi + * /termix-id/ca: + * post: + * summary: Create a certificate authority + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * validityDays: + * type: integer + * responses: + * 201: + * description: Created CA + */ +router.post( + "/ca", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) { + return res + .status(400) + .json({ error: "Create a Termix ID handle first" }); + } + const existing = await db + .select({ id: termixIdentityCa.id }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identity.id)) + .limit(1); + if (existing.length > 0) { + return res.status(409).json({ error: "CA already exists" }); + } + + const validityDays = clampValidityDays(req.body?.validityDays, 90); + const generated = generateCa(); + await SimpleDBOps.insert( + termixIdentityCa, + "termix_identity_ca", + { + identityId: identity.id, + userId, + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + }, + userId, + ); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "create_termix_id_ca", + resourceType: "termix_id_ca", + resourceName: identity.handle, + ipAddress, + userAgent, + success: true, + }); + + res.status(201).json({ + publicKey: generated.publicKeyLine, + validityDays, + resolverPath: `/termix-id/u/${identity.handle}/ca`, + }); + } catch (err) { + authLogger.error("Failed to create Termix ID CA", err); + res.status(500).json({ error: "Failed to create CA" }); + } + }, +); + +/** + * @openapi + * /termix-id/ca/rotate: + * post: + * summary: Rotate the certificate authority (revokes all issued certificates) + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * validityDays: + * type: integer + * responses: + * 200: + * description: New CA public key + */ +router.post( + "/ca/rotate", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) + return res.status(404).json({ error: "No Termix ID found" }); + const existing = await db + .select({ + id: termixIdentityCa.id, + validityDays: termixIdentityCa.validityDays, + }) + .from(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identity.id)) + .limit(1); + if (existing.length === 0) { + return res.status(404).json({ error: "No CA to rotate" }); + } + + const validityDays = clampValidityDays( + req.body?.validityDays, + existing[0].validityDays, + ); + const generated = generateCa(); + // Rotating invalidates every previously issued certificate — this IS the + // central revocation mechanism. + await SimpleDBOps.update( + termixIdentityCa, + "termix_identity_ca", + eq(termixIdentityCa.identityId, identity.id), + { + publicKey: generated.publicKeyLine, + privateKey: generated.privateKeyPem, + validityDays, + updatedAt: new Date().toISOString(), + }, + userId, + ); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "rotate_termix_id_ca", + resourceType: "termix_id_ca", + resourceName: identity.handle, + ipAddress, + userAgent, + success: true, + }); + + res.json({ + publicKey: generated.publicKeyLine, + validityDays, + resolverPath: `/termix-id/u/${identity.handle}/ca`, + }); + } catch (err) { + authLogger.error("Failed to rotate Termix ID CA", err); + res.status(500).json({ error: "Failed to rotate CA" }); + } + }, +); + +/** + * @openapi + * /termix-id/ca: + * delete: + * summary: Delete the certificate authority + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Deleted + */ +router.delete("/ca", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) return res.status(404).json({ error: "No Termix ID found" }); + const deleted = await db + .delete(termixIdentityCa) + .where(eq(termixIdentityCa.identityId, identity.id)) + .returning(); + if (deleted.length === 0) { + return res.status(404).json({ error: "No CA to delete" }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "delete_termix_id_ca", + resourceType: "termix_id_ca", + resourceName: identity.handle, + ipAddress, + userAgent, + success: true, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete Termix ID CA", err); + res.status(500).json({ error: "Failed to delete CA" }); + } +}); + +/** + * @openapi + * /termix-id/keys/{id}/certificate: + * post: + * summary: Issue an SSH certificate for a key (ed25519 only) + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * validityDays: + * type: integer + * principals: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Issued certificate + */ +router.post( + "/keys/:id/certificate", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(String(req.params.id), 10); + if (Number.isNaN(id)) { + return res.status(400).json({ error: "Invalid key id" }); + } + try { + const keyRows = await db + .select() + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.id, id), + eq(termixIdentityKeys.userId, userId), + ), + ) + .limit(1); + if (keyRows.length === 0) { + return res.status(404).json({ error: "Key not found" }); + } + const key = keyRows[0]; + if (!ed25519RawFromLine(key.publicKey)) { + return res.status(400).json({ + error: "Certificates are only supported for Ed25519 keys", + }); + } + + const identity = await getIdentityForUser(userId); + if (!identity) + return res.status(404).json({ error: "No Termix ID found" }); + const ca = await getCaForUser(userId, identity.id); + if (!ca) { + return res + .status(400) + .json({ error: "Enable a certificate authority first" }); + } + + const validityDays = clampValidityDays( + req.body?.validityDays, + ca.validityDays, + ); + const principals = Array.isArray(req.body?.principals) + ? req.body.principals + .filter((p: string) => typeof p === "string" && p.trim()) + .map((p: string) => p.trim()) + .slice(0, 32) + : []; + + const now = Math.floor(Date.now() / 1000); + const validBefore = now + validityDays * 86400; + const keyId = `termix:@${identity.handle}:${id}`; + const certificate = signUserCertificate({ + userPublicKeyLine: key.publicKey, + caPrivateKeyPem: ca.privateKey, + caPublicKeyLine: ca.publicKey, + keyId, + principals, + validAfter: now - 60, + validBefore, + }); + if (!certificate) { + return res.status(500).json({ error: "Failed to sign certificate" }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getActorUsername(userId), + action: "issue_termix_id_certificate", + resourceType: "termix_id_key", + resourceId: String(id), + resourceName: identity.handle, + details: `validity ${validityDays}d${principals.length ? `, principals: ${principals.join(",")}` : ""}`, + ipAddress, + userAgent, + success: true, + }); + + res.json({ certificate, keyId, validBefore, principals, validityDays }); + } catch (err) { + authLogger.error("Failed to issue Termix ID certificate", err); + res.status(500).json({ error: "Failed to issue certificate" }); + } + }, +); + +/** + * @openapi + * /termix-id/linked-credentials: + * get: + * summary: Get credential IDs that have at least one enabled published Termix ID key + * tags: [Termix ID] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: List of credential IDs + */ +router.get( + "/linked-credentials", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const identity = await getIdentityForUser(userId); + if (!identity) return res.json({ credentialIds: [] }); + const keys = await db + .select({ credentialId: termixIdentityKeys.credentialId }) + .from(termixIdentityKeys) + .where( + and( + eq(termixIdentityKeys.identityId, identity.id), + eq(termixIdentityKeys.enabled, true), + ), + ); + const credentialIds = [ + ...new Set( + keys + .map((k) => k.credentialId) + .filter((cid): cid is number => cid !== null), + ), + ]; + res.json({ credentialIds }); + } catch (err) { + authLogger.error("Failed to fetch linked credential IDs", err); + res.status(500).json({ error: "Failed to fetch linked credentials" }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts index 749796a7..c2dce5a2 100644 --- a/src/backend/database/routes/user-password-reset-routes.ts +++ b/src/backend/database/routes/user-password-reset-routes.ts @@ -18,6 +18,7 @@ import { sshCredentialUsage, recentActivity, snippets, + webauthnCredentials, } from "../db/schema.js"; interface UserPasswordResetRoutesDeps { @@ -440,6 +441,9 @@ export function registerUserPasswordResetRoutes( await db .delete(sshCredentials) .where(eq(sshCredentials.userId, userId)); + await db + .delete(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)); await authManager.registerUser(userId, newPassword); authManager.logoutUser(userId); diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index c99d6054..6dbc8d5a 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -23,6 +23,7 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ showHostTags: row?.showHostTags ?? null, hostTrayOnClick: row?.hostTrayOnClick ?? null, pinAppRail: row?.pinAppRail ?? null, + expandAppRailOnHover: row?.expandAppRailOnHover ?? null, foldersCollapsed: row?.foldersCollapsed ?? null, confirmSnippetExecution: row?.confirmSnippetExecution ?? null, disableUpdateCheck: row?.disableUpdateCheck ?? null, @@ -79,6 +80,9 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({ * pinAppRail: * type: boolean * nullable: true + * expandAppRailOnHover: + * type: boolean + * nullable: true * foldersCollapsed: * type: boolean * nullable: true @@ -156,6 +160,8 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => { * type: boolean * pinAppRail: * type: boolean + * expandAppRailOnHover: + * type: boolean * foldersCollapsed: * type: boolean * confirmSnippetExecution: @@ -188,6 +194,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { showHostTags, hostTrayOnClick, pinAppRail, + expandAppRailOnHover, foldersCollapsed, confirmSnippetExecution, disableUpdateCheck, @@ -207,6 +214,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { showHostTags?: boolean | null; hostTrayOnClick?: boolean | null; pinAppRail?: boolean | null; + expandAppRailOnHover?: boolean | null; foldersCollapsed?: boolean | null; confirmSnippetExecution?: boolean | null; disableUpdateCheck?: boolean | null; @@ -249,6 +257,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { showHostTags, hostTrayOnClick, pinAppRail, + expandAppRailOnHover, foldersCollapsed, confirmSnippetExecution, disableUpdateCheck, @@ -274,6 +283,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => { if (showHostTags !== undefined) updates.showHostTags = showHostTags; if (hostTrayOnClick !== undefined) updates.hostTrayOnClick = hostTrayOnClick; if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail; + if (expandAppRailOnHover !== undefined) + updates.expandAppRailOnHover = expandAppRailOnHover; if (foldersCollapsed !== undefined) updates.foldersCollapsed = foldersCollapsed; if (confirmSnippetExecution !== undefined) diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 2d82cbd9..047eda3b 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -10,10 +10,10 @@ import { import { db } from "../db/index.js"; import { users } from "../db/schema.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; - -function getDefaultGuacUrl(): string { - return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; -} +import { + formatGuacdOptions, + resolveGuacdOptions, +} from "../../utils/guacd-config.js"; export type HostDefaults = { useSocks5?: boolean; @@ -70,7 +70,7 @@ export function registerUserSettingsRoutes( .get() as { value: string } | undefined; res.json({ enabled: enabledRow ? enabledRow.value !== "false" : true, - url: urlRow ? urlRow.value : getDefaultGuacUrl(), + url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), }); } catch (err) { authLogger.error("Failed to get guacamole settings", err); @@ -161,7 +161,7 @@ export function registerUserSettingsRoutes( res.json({ enabled: enabledRow ? enabledRow.value !== "false" : true, - url: urlRow ? urlRow.value : getDefaultGuacUrl(), + url: formatGuacdOptions(resolveGuacdOptions(urlRow?.value)), }); } catch (err) { authLogger.error("Failed to update guacamole settings", err); diff --git a/src/backend/database/routes/user-webauthn-routes.ts b/src/backend/database/routes/user-webauthn-routes.ts new file mode 100644 index 00000000..6eff114b --- /dev/null +++ b/src/backend/database/routes/user-webauthn-routes.ts @@ -0,0 +1,514 @@ +import type { Request, RequestHandler, Router } from "express"; +import type { + AuthenticationResponseJSON, + RegistrationResponseJSON, + AuthenticatorTransportFuture, + Base64URLString, + WebAuthnCredential, +} from "@simplewebauthn/server"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import { and, eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { authLogger } from "../../utils/logger.js"; +import { + generateDeviceFingerprint, + parseUserAgent, +} from "../../utils/user-agent-parser.js"; +import { db, saveMemoryDatabaseToFile } from "../db/index.js"; +import { users, webauthnCredentials } from "../db/schema.js"; + +type UserVerification = "discouraged" | "preferred" | "required"; +type NativeAppRequestChecker = (req: Request) => boolean; + +interface WebAuthnRoutesDeps { + authenticateJWT: RequestHandler; + authManager: AuthManager; + isNativeAppRequest: NativeAppRequestChecker; +} + +interface ChallengeRecord { + challenge: string; + userId?: string; + rpID: string; + origin: string; + userVerification: UserVerification; + createdAt: number; +} + +const challengeTtlMs = 5 * 60 * 1000; +const registrationChallenges = new Map(); +const authenticationChallenges = new Map(); + +function normalizeUserVerification(value: unknown): UserVerification { + return value === "discouraged" || value === "required" ? value : "preferred"; +} + +function getRequestOrigin(req: Request): string { + const origin = req.get("origin"); + if (origin) return origin; + + const proto = req.get("x-forwarded-proto") || req.protocol || "http"; + const host = req.get("x-forwarded-host") || req.get("host") || "localhost"; + return `${proto.split(",")[0]}://${host.split(",")[0]}`; +} + +function getRpID(origin: string): string { + return new URL(origin).hostname; +} + +function pruneChallenges(map: Map): void { + const now = Date.now(); + for (const [id, record] of map) { + if (now - record.createdAt > challengeTtlMs) { + map.delete(id); + } + } +} + +function putChallenge( + map: Map, + record: Omit, +): string { + pruneChallenges(map); + const challengeId = nanoid(); + map.set(challengeId, { ...record, createdAt: Date.now() }); + return challengeId; +} + +function takeChallenge( + map: Map, + challengeId: unknown, +): ChallengeRecord | null { + if (typeof challengeId !== "string") return null; + pruneChallenges(map); + const record = map.get(challengeId); + if (!record) return null; + map.delete(challengeId); + return record; +} + +function toBase64Url(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function fromBase64Url(value: string): Uint8Array { + return Uint8Array.from(Buffer.from(value, "base64url")); +} + +function parseTransports(value: string | null): AuthenticatorTransportFuture[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function getCredentialForVerification( + credential: typeof webauthnCredentials.$inferSelect, +): WebAuthnCredential { + return { + id: credential.credentialId as Base64URLString, + publicKey: fromBase64Url( + credential.publicKey, + ) as WebAuthnCredential["publicKey"], + counter: credential.counter, + transports: parseTransports(credential.transports), + }; +} + +export function registerUserWebAuthnRoutes( + router: Router, + { authenticateJWT, authManager, isNativeAppRequest }: WebAuthnRoutesDeps, +): void { + router.get("/webauthn/credentials", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const credentials = await db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)); + + res.json({ + credentials: credentials.map((credential) => ({ + id: credential.id, + name: credential.name, + deviceType: credential.deviceType, + backedUp: credential.backedUp, + transports: parseTransports(credential.transports), + userVerification: credential.userVerification, + createdAt: credential.createdAt, + lastUsedAt: credential.lastUsedAt, + })), + }); + }); + + router.post( + "/webauthn/register/options", + authenticateJWT, + async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + if (!authManager.getUserDataKey(userId)) { + return res.status(401).json({ + error: "User data is locked. Log in again before adding a passkey.", + }); + } + + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user.length) { + return res.status(404).json({ error: "User not found" }); + } + + const existing = await db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)); + + const origin = getRequestOrigin(req); + const rpID = getRpID(origin); + const userVerification = normalizeUserVerification( + req.body?.userVerification, + ); + + const options = await generateRegistrationOptions({ + rpName: "Termix", + rpID, + userID: Buffer.from(userId, "utf8"), + userName: user[0].username, + userDisplayName: user[0].username, + attestationType: "none", + excludeCredentials: existing.map((credential) => ({ + id: credential.credentialId as Base64URLString, + transports: parseTransports(credential.transports), + })), + authenticatorSelection: { + residentKey: "required", + userVerification, + }, + }); + + const challengeId = putChallenge(registrationChallenges, { + challenge: options.challenge, + userId, + rpID, + origin, + userVerification, + }); + + res.json({ options, challengeId }); + }, + ); + + router.post( + "/webauthn/register/verify", + authenticateJWT, + async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const challenge = takeChallenge( + registrationChallenges, + req.body?.challengeId, + ); + if (!challenge || challenge.userId !== userId) { + return res + .status(400) + .json({ error: "Registration challenge expired" }); + } + + try { + const verification = await verifyRegistrationResponse({ + response: req.body?.response as RegistrationResponseJSON, + expectedChallenge: challenge.challenge, + expectedOrigin: challenge.origin, + expectedRPID: challenge.rpID, + requireUserVerification: challenge.userVerification === "required", + }); + + if (!verification.verified) { + return res.status(400).json({ error: "Passkey registration failed" }); + } + + const { credential, credentialDeviceType, credentialBackedUp } = + verification.registrationInfo; + const transports = + (req.body?.response as RegistrationResponseJSON | undefined)?.response + ?.transports ?? []; + + await authManager.setupWebAuthnUserEncryption(userId); + + const name = + typeof req.body?.name === "string" && req.body.name.trim() + ? req.body.name.trim().slice(0, 80) + : "Passkey"; + + await db.insert(webauthnCredentials).values({ + id: nanoid(), + userId, + name, + credentialId: credential.id, + publicKey: toBase64Url(credential.publicKey), + counter: credential.counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: JSON.stringify(transports), + userVerification: challenge.userVerification, + createdAt: new Date().toISOString(), + }); + + await saveMemoryDatabaseToFile(); + res.json({ success: true }); + } catch (error) { + authLogger.warn("WebAuthn registration failed", { + operation: "webauthn_register_verify", + userId, + error: error instanceof Error ? error.message : "Unknown", + }); + res.status(400).json({ error: "Passkey registration failed" }); + } + }, + ); + + router.post("/webauthn/authenticate/options", async (req, res) => { + const origin = getRequestOrigin(req); + const rpID = getRpID(origin); + const userVerification = normalizeUserVerification( + req.body?.userVerification, + ); + const username = + typeof req.body?.username === "string" ? req.body.username.trim() : ""; + + let userId: string | undefined; + let allowCredentials: + | { id: Base64URLString; transports?: AuthenticatorTransportFuture[] }[] + | undefined; + + if (username) { + const user = await db + .select() + .from(users) + .where(eq(users.username, username)); + if (!user.length) { + return res.status(404).json({ error: "No passkeys found" }); + } + + userId = user[0].id; + const credentials = await db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.userId, userId)); + + if (!credentials.length) { + return res.status(404).json({ error: "No passkeys found" }); + } + + allowCredentials = credentials.map((credential) => ({ + id: credential.credentialId as Base64URLString, + transports: parseTransports(credential.transports), + })); + } + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials, + userVerification, + }); + + const challengeId = putChallenge(authenticationChallenges, { + challenge: options.challenge, + userId, + rpID, + origin, + userVerification, + }); + + res.json({ options, challengeId }); + }); + + router.post("/webauthn/authenticate/verify", async (req, res) => { + const challenge = takeChallenge( + authenticationChallenges, + req.body?.challengeId, + ); + if (!challenge) { + return res + .status(400) + .json({ error: "Authentication challenge expired" }); + } + + const response = req.body?.response as + | AuthenticationResponseJSON + | undefined; + if (!response?.id) { + return res.status(400).json({ error: "Invalid passkey response" }); + } + + const credentials = await db + .select() + .from(webauthnCredentials) + .where(eq(webauthnCredentials.credentialId, response.id)); + + if (!credentials.length) { + return res.status(401).json({ error: "Passkey not recognized" }); + } + + const credential = credentials[0]; + if (challenge.userId && challenge.userId !== credential.userId) { + return res.status(401).json({ error: "Passkey not recognized" }); + } + + try { + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: challenge.origin, + expectedRPID: challenge.rpID, + credential: getCredentialForVerification(credential), + requireUserVerification: challenge.userVerification === "required", + advancedFIDOConfig: { + userVerification: challenge.userVerification, + }, + }); + + if (!verification.verified) { + return res.status(401).json({ error: "Passkey authentication failed" }); + } + + const user = await db + .select() + .from(users) + .where(eq(users.id, credential.userId)); + if (!user.length) { + return res.status(404).json({ error: "User not found" }); + } + + const userRecord = user[0]; + const deviceInfo = parseUserAgent(req); + const dataUnlocked = await authManager.authenticateWebAuthnUser( + userRecord.id, + deviceInfo.type, + ); + + if (!dataUnlocked) { + return res.status(401).json({ + error: + "Passkey cannot unlock this account. Log in with password and register the passkey again.", + }); + } + + await db + .update(webauthnCredentials) + .set({ + counter: verification.authenticationInfo.newCounter, + backedUp: verification.authenticationInfo.credentialBackedUp, + deviceType: verification.authenticationInfo.credentialDeviceType, + lastUsedAt: new Date().toISOString(), + }) + .where(eq(webauthnCredentials.id, credential.id)); + + if (userRecord.totpEnabled) { + const deviceFingerprint = generateDeviceFingerprint(deviceInfo); + const isTrusted = await authManager.isTrustedDevice( + userRecord.id, + deviceFingerprint, + ); + + if (!isTrusted) { + await saveMemoryDatabaseToFile(); + const tempToken = await authManager.generateJWTToken(userRecord.id, { + pendingTOTP: true, + expiresIn: "10m", + }); + return res.json({ + success: true, + requires_totp: true, + temp_token: tempToken, + rememberMe: !!req.body?.rememberMe, + }); + } + } + + const token = await authManager.generateJWTToken(userRecord.id, { + rememberMe: !!req.body?.rememberMe, + deviceType: deviceInfo.type, + deviceInfo: deviceInfo.deviceInfo, + }); + + await saveMemoryDatabaseToFile(); + + const timeoutRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'session_timeout_hours'", + ) + .get() as { value: string } | undefined; + const timeoutHours = timeoutRow + ? parseInt(timeoutRow.value, 10) || 24 + : 24; + const maxAge = req.body?.rememberMe + ? 30 * 24 * 60 * 60 * 1000 + : timeoutHours * 60 * 60 * 1000; + + res.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)); + res.json({ + success: true, + is_admin: !!userRecord.isAdmin, + username: userRecord.username, + userId: userRecord.id, + is_oidc: !!userRecord.isOidc, + totp_enabled: !!userRecord.totpEnabled, + data_unlocked: true, + ...(isNativeAppRequest(req) ? { token } : {}), + }); + } catch (error) { + authLogger.warn("WebAuthn authentication failed", { + operation: "webauthn_auth_verify", + credentialId: credential.id, + userId: credential.userId, + error: error instanceof Error ? error.message : "Unknown", + }); + res.status(401).json({ error: "Passkey authentication failed" }); + } + }); + + router.delete( + "/webauthn/credentials/:credentialId", + authenticateJWT, + async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const credentialId = String(req.params.credentialId); + + await db + .delete(webauthnCredentials) + .where( + and( + eq(webauthnCredentials.id, credentialId), + eq(webauthnCredentials.userId, userId), + ), + ); + await saveMemoryDatabaseToFile(); + + res.json({ success: true }); + }, + ); +} diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d25a263d..1f18b525 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -14,7 +14,10 @@ import { generateDeviceFingerprint, } from "../../utils/user-agent-parser.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; -import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; +import { + getRequestBasePath, + getRequestBaseUrlWithForceHTTPS, +} from "../../utils/request-origin.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; import { getOIDCConfigFromEnv, @@ -33,6 +36,7 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; import { registerUserAdminRoutes } from "./user-admin-routes.js"; import { registerUserDataAccessRoutes } from "./user-data-access-routes.js"; +import { registerUserWebAuthnRoutes } from "./user-webauthn-routes.js"; import { registerSSOProviderRoutes } from "./sso-provider-routes.js"; import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; @@ -41,6 +45,25 @@ const authManager = AuthManager.getInstance(); const router = express.Router(); +async function syncSharedCredentialsForUserRoles( + userId: string, + operation: string, +) { + try { + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); + const sharedCredManager = SharedCredentialManager.getInstance(); + await sharedCredManager.createSharedCredentialsForUserRoles(userId); + await sharedCredManager.reEncryptPendingCredentialsForUser(userId); + } catch (error) { + authLogger.warn("Failed to sync role shared credentials", { + operation, + userId, + error, + }); + } +} + function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } @@ -610,8 +633,8 @@ router.get("/oidc/authorize", async (req, res) => { appCallbackUrl, providerId: providerIdStr, } = req.query; - const origin = getRequestOriginWithForceHTTPS(req); - const backendCallbackUri = `${origin}/users/oidc/callback`; + const publicBaseUrl = getRequestBaseUrlWithForceHTTPS(req); + const backendCallbackUri = `${publicBaseUrl}/users/oidc/callback`; const resolvedProviderId = providerIdStr ? parseInt(providerIdStr as string, 10) @@ -643,9 +666,9 @@ router.get("/oidc/authorize", async (req, res) => { frontendOrigin = callbackUrl.toString(); } else if (referer) { const refererUrl = new URL(referer); - frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; + frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}${getRequestBasePath(req)}`; } else { - frontendOrigin = origin; + frontendOrigin = publicBaseUrl; } db.$client @@ -946,7 +969,7 @@ router.get("/oidc/callback", async (req, res) => { ? 30 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000; await authManager.registerOIDCUser(ghId, sessionDurationMs); - } catch (encryptionError) { + } catch { await db.delete(users).where(eq(users.id, ghId)); return res.status(500).json({ error: "Failed to setup user security - user creation cancelled", @@ -965,6 +988,10 @@ router.get("/oidc/callback", async (req, res) => { } catch { /* */ } + await syncSharedCredentialsForUserRoles( + ghUserRecord.id, + "github_oidc_role_shared_credentials", + ); const ghToken = await authManager.generateJWTToken(ghUserRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, @@ -1432,14 +1459,10 @@ router.get("/oidc/callback", async (req, res) => { }); } - try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id); - } catch { - // expected - re-encryption may fail if no pending credentials - } + await syncSharedCredentialsForUserRoles( + userRecord.id, + "oidc_role_shared_credentials", + ); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, @@ -1642,18 +1665,10 @@ router.post("/login", async (req, res) => { return res.status(401).json({ error: "Incorrect password" }); } - try { - const { SharedCredentialManager } = - await import("../../utils/shared-credential-manager.js"); - const sharedCredManager = SharedCredentialManager.getInstance(); - await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id); - } catch (error) { - authLogger.warn("Failed to re-encrypt pending shared credentials", { - operation: "reencrypt_pending_credentials", - userId: userRecord.id, - error, - }); - } + await syncSharedCredentialsForUserRoles( + userRecord.id, + "login_role_shared_credentials", + ); if (userRecord.totpEnabled) { const deviceFingerprint = generateDeviceFingerprint(deviceInfo); @@ -2520,6 +2535,12 @@ registerUserTotpRoutes(router, { isNativeAppRequest, }); +registerUserWebAuthnRoutes(router, { + authenticateJWT, + authManager, + isNativeAppRequest, +}); + /** * @openapi * /users/delete-user: diff --git a/src/backend/database/routes/vault.ts b/src/backend/database/routes/vault.ts new file mode 100644 index 00000000..502b34eb --- /dev/null +++ b/src/backend/database/routes/vault.ts @@ -0,0 +1,450 @@ +import express from "express"; +import type { Request, Response } from "express"; +import { desc, eq, or } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users, vaultProfiles } from "../db/schema.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { completeVaultAuth } from "../../ssh/vault-oidc-auth.js"; + +const router = express.Router(); + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +function isNonEmptyString(val: unknown): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +async function userIsAdmin(userId: string): Promise { + try { + const rows = await db + .select({ isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return !!rows[0]?.isAdmin; + } catch { + return false; + } +} + +function formatProfile( + row: Record, + currentUserId: string, +): Record { + return { + id: row.id, + name: row.name, + description: row.description, + folder: row.folder, + tags: + typeof row.tags === "string" + ? row.tags + ? (row.tags as string).split(",").filter(Boolean) + : [] + : [], + vaultAddr: row.vaultAddr, + vaultNamespace: row.vaultNamespace, + oidcMount: row.oidcMount, + oidcRole: row.oidcRole, + sshMount: row.sshMount, + sshRole: row.sshRole, + validPrincipals: row.validPrincipals, + keyType: row.keyType, + shared: !!row.shared, + owned: row.userId === currentUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * @openapi + * /vault/oidc/callback: + * get: + * summary: Vault OIDC callback + * description: Unauthenticated endpoint the IdP redirects to after login. Correlates the authorization code to a pending session via the Vault-issued state parameter. + * tags: + * - Vault + * parameters: + * - in: query + * name: state + * required: true + * schema: + * type: string + * - in: query + * name: code + * required: true + * schema: + * type: string + * - in: query + * name: error + * schema: + * type: string + * responses: + * 200: + * description: HTML page confirming sign-in success or failure. + * 400: + * description: Missing parameters or authentication failure. + */ +router.get("/oidc/callback", async (req: Request, res: Response) => { + const state = String(req.query.state || ""); + const code = String(req.query.code || ""); + const oidcError = req.query.error ? String(req.query.error) : ""; + + const html = (title: string, message: string) => + `${title} + +

${title}

${message}

+
`; + + if (oidcError) { + return res + .status(400) + .send(html("Vault sign-in failed", `Vault returned: ${oidcError}`)); + } + if (!state || !code) { + return res + .status(400) + .send(html("Vault sign-in failed", "Missing state or code.")); + } + + const result = await completeVaultAuth(state, code); + if (result.ok) { + return res.send( + html( + "Vault sign-in complete", + "You can close this window and return to Termix.", + ), + ); + } + return res + .status(400) + .send( + html("Vault sign-in failed", result.error || "Authentication failed."), + ); +}); + +/** + * @openapi + * /vault/profiles: + * get: + * summary: List Vault profiles + * description: Returns all Vault signer profiles owned by the authenticated user or marked as shared. + * tags: + * - Vault + * responses: + * 200: + * description: Array of Vault profile objects. + * 500: + * description: Failed to list vault profiles. + */ +router.get( + "/profiles", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const rows = await db + .select() + .from(vaultProfiles) + .where( + or(eq(vaultProfiles.userId, userId), eq(vaultProfiles.shared, true)), + ) + .orderBy(desc(vaultProfiles.updatedAt)); + res.json( + rows.map((r) => formatProfile(r as Record, userId)), + ); + } catch (err) { + authLogger.error("Failed to list vault profiles", err); + res.status(500).json({ error: "Failed to list vault profiles" }); + } + }, +); + +/** + * @openapi + * /vault/profiles: + * post: + * summary: Create a Vault profile + * description: Creates a new Vault signer profile owned by the authenticated user. The shared flag requires admin privileges. + * tags: + * - Vault + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - vaultAddr + * - sshRole + * properties: + * name: + * type: string + * vaultAddr: + * type: string + * vaultNamespace: + * type: string + * oidcMount: + * type: string + * oidcRole: + * type: string + * sshMount: + * type: string + * sshRole: + * type: string + * validPrincipals: + * type: string + * keyType: + * type: string + * shared: + * type: boolean + * responses: + * 201: + * description: Created Vault profile object. + * 400: + * description: Missing required fields. + * 403: + * description: Non-admin attempted to create a shared profile. + * 500: + * description: Failed to create vault profile. + */ +router.post( + "/profiles", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { + name, + description, + folder, + tags, + vaultAddr, + vaultNamespace, + oidcMount, + oidcRole, + sshMount, + sshRole, + validPrincipals, + keyType, + shared, + } = req.body; + + if ( + !isNonEmptyString(name) || + !isNonEmptyString(vaultAddr) || + !isNonEmptyString(sshRole) + ) { + return res + .status(400) + .json({ error: "name, vaultAddr and sshRole are required" }); + } + + const wantShared = !!shared; + if (wantShared && !(await userIsAdmin(userId))) { + return res.status(403).json({ + error: "Only administrators can create shared Vault profiles", + }); + } + + try { + const inserted = await db + .insert(vaultProfiles) + .values({ + userId, + name: name.trim(), + description: description?.trim() || null, + folder: folder?.trim() || null, + tags: Array.isArray(tags) ? tags.join(",") : tags || "", + vaultAddr: vaultAddr.trim(), + vaultNamespace: vaultNamespace?.trim() || null, + oidcMount: oidcMount?.trim() || null, + oidcRole: oidcRole?.trim() || null, + sshMount: sshMount?.trim() || null, + sshRole: sshRole.trim(), + validPrincipals: validPrincipals?.trim() || null, + keyType: keyType?.trim() || null, + shared: wantShared, + }) + .returning(); + res + .status(201) + .json(formatProfile(inserted[0] as Record, userId)); + } catch (err) { + authLogger.error("Failed to create vault profile", err); + res.status(500).json({ error: "Failed to create vault profile" }); + } + }, +); + +/** + * @openapi + * /vault/profiles/{id}: + * put: + * summary: Update a Vault profile + * description: Updates a Vault signer profile. Only the owner may edit; toggling shared to true requires admin privileges. + * tags: + * - Vault + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * content: + * application/json: + * schema: + * type: object + * responses: + * 200: + * description: Updated Vault profile object. + * 400: + * description: Invalid profile id. + * 403: + * description: Non-owner attempted to edit, or non-admin attempted to share. + * 404: + * description: Profile not found. + * 500: + * description: Failed to update vault profile. + */ +router.put( + "/profiles/:id", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(String(req.params.id), 10); + if (!Number.isFinite(id)) { + return res.status(400).json({ error: "Invalid profile id" }); + } + + try { + const existing = await db + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .limit(1); + if (!existing.length) { + return res.status(404).json({ error: "Profile not found" }); + } + if (existing[0].userId !== userId) { + return res + .status(403) + .json({ error: "Only the owner can edit this profile" }); + } + + const body = req.body; + const fields: Record = { + updatedAt: new Date().toISOString(), + }; + if (body.name !== undefined) fields.name = body.name?.trim(); + if (body.description !== undefined) + fields.description = body.description?.trim() || null; + if (body.folder !== undefined) + fields.folder = body.folder?.trim() || null; + if (body.tags !== undefined) + fields.tags = Array.isArray(body.tags) + ? body.tags.join(",") + : body.tags || ""; + if (body.vaultAddr !== undefined && isNonEmptyString(body.vaultAddr)) + fields.vaultAddr = body.vaultAddr.trim(); + if (body.vaultNamespace !== undefined) + fields.vaultNamespace = body.vaultNamespace?.trim() || null; + if (body.oidcMount !== undefined) + fields.oidcMount = body.oidcMount?.trim() || null; + if (body.oidcRole !== undefined) + fields.oidcRole = body.oidcRole?.trim() || null; + if (body.sshMount !== undefined) + fields.sshMount = body.sshMount?.trim() || null; + if (body.sshRole !== undefined && isNonEmptyString(body.sshRole)) + fields.sshRole = body.sshRole.trim(); + if (body.validPrincipals !== undefined) + fields.validPrincipals = body.validPrincipals?.trim() || null; + if (body.keyType !== undefined) + fields.keyType = body.keyType?.trim() || null; + if (body.shared !== undefined) { + if (!!body.shared && !(await userIsAdmin(userId))) { + return res.status(403).json({ + error: "Only administrators can share Vault profiles", + }); + } + fields.shared = !!body.shared; + } + + const updated = await db + .update(vaultProfiles) + .set(fields) + .where(eq(vaultProfiles.id, id)) + .returning(); + res.json(formatProfile(updated[0] as Record, userId)); + } catch (err) { + authLogger.error("Failed to update vault profile", err); + res.status(500).json({ error: "Failed to update vault profile" }); + } + }, +); + +/** + * @openapi + * /vault/profiles/{id}: + * delete: + * summary: Delete a Vault profile + * description: Permanently deletes a Vault signer profile. Only the owner may delete it. + * tags: + * - Vault + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Deletion confirmed. + * 400: + * description: Invalid profile id. + * 403: + * description: Non-owner attempted to delete. + * 404: + * description: Profile not found. + * 500: + * description: Failed to delete vault profile. + */ +router.delete( + "/profiles/:id", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const id = parseInt(String(req.params.id), 10); + if (!Number.isFinite(id)) { + return res.status(400).json({ error: "Invalid profile id" }); + } + try { + const existing = await db + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, id)) + .limit(1); + if (!existing.length) { + return res.status(404).json({ error: "Profile not found" }); + } + if (existing[0].userId !== userId) { + return res + .status(403) + .json({ error: "Only the owner can delete this profile" }); + } + await db.delete(vaultProfiles).where(eq(vaultProfiles.id, id)); + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete vault profile", err); + res.status(500).json({ error: "Failed to delete vault profile" }); + } + }, +); + +export default router; diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index 61dbb147..746007af 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -2,34 +2,22 @@ import GuacamoleLite from "guacamole-lite"; import { guacLogger } from "../utils/logger.js"; import { GuacamoleTokenService } from "./token-service.js"; import { getDb } from "../database/db/index.js"; +import { resolveGuacdOptions } from "../utils/guacd-config.js"; const tokenService = GuacamoleTokenService.getInstance(); -function parseGuacUrl(url: string): { host: string; port: number } { - const parts = url.split(":"); - return { - host: parts[0] || "localhost", - port: parseInt(parts[1] || "4822", 10), - }; -} - function readGuacdOptions(): { host: string; port: number } { - let host = process.env.GUACD_HOST || "localhost"; - let port = parseInt(process.env.GUACD_PORT || "4822", 10); + let dbUrl: string | undefined; try { const db = getDb(); const urlRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get() as { value: string } | undefined; - if (urlRow?.value) { - const parsed = parseGuacUrl(urlRow.value); - host = parsed.host; - port = parsed.port; - } + dbUrl = urlRow?.value; } catch { // DB not available yet, use env var defaults } - return { host, port }; + return resolveGuacdOptions(dbUrl); } const GUAC_WS_PORT = 30008; diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts index a30f789a..2e3e0da0 100644 --- a/src/backend/guacamole/routes.ts +++ b/src/backend/guacamole/routes.ts @@ -10,6 +10,7 @@ import { eq, and } from "drizzle-orm"; import { Client } from "ssh2"; import net from "net"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { resolveGuacdOptions } from "../utils/guacd-config.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); @@ -589,21 +590,17 @@ router.post( */ router.get("/status", async (req, res) => { try { - let guacdHost = process.env.GUACD_HOST || "localhost"; - let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10); + let dbUrl: string | undefined; try { const db = getDb(); const urlRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get() as { value: string } | undefined; - if (urlRow?.value) { - const parts = urlRow.value.split(":"); - guacdHost = parts[0] || guacdHost; - guacdPort = parseInt(parts[1] || String(guacdPort), 10); - } + dbUrl = urlRow?.value; } catch { // Fall back to env vars } + const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl); const net = await import("net"); diff --git a/src/backend/guacamole/token-service.test.ts b/src/backend/guacamole/token-service.test.ts new file mode 100644 index 00000000..6ad3110a --- /dev/null +++ b/src/backend/guacamole/token-service.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../utils/logger.js", () => ({ + guacLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +const { GuacamoleTokenService } = await import("./token-service.js"); + +describe("GuacamoleTokenService", () => { + const tokenService = GuacamoleTokenService.getInstance(); + + it("disables RDP pre-authentication when no credentials are configured", () => { + const token = tokenService.createRdpToken("windows.example.test", "", ""); + const decrypted = tokenService.decryptToken(token); + + expect(decrypted?.connection.settings).toMatchObject({ + hostname: "windows.example.test", + port: 3389, + "ignore-cert": true, + "disable-auth": true, + }); + expect(decrypted?.connection.settings.username).toBeUndefined(); + expect(decrypted?.connection.settings.password).toBeUndefined(); + }); + + it("keeps normal RDP credential authentication unchanged", () => { + const token = tokenService.createRdpToken( + "windows.example.test", + "Administrator", + "secret", + ); + const decrypted = tokenService.decryptToken(token); + + expect(decrypted?.connection.settings).toMatchObject({ + hostname: "windows.example.test", + username: "Administrator", + password: "secret", + port: 3389, + }); + expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined(); + }); +}); diff --git a/src/backend/guacamole/token-service.ts b/src/backend/guacamole/token-service.ts index 0142ec94..f3322e06 100644 --- a/src/backend/guacamole/token-service.ts +++ b/src/backend/guacamole/token-service.ts @@ -16,6 +16,7 @@ export interface GuacamoleConnectionSettings { dpi?: number; security?: string; "ignore-cert"?: boolean; + "disable-auth"?: boolean; "enable-wallpaper"?: boolean; "enable-drive"?: boolean; "drive-path"?: string; @@ -139,6 +140,7 @@ export class GuacamoleTokenService { ...(password ? { password } : {}), port: 3389, "ignore-cert": true, + ...(!username && !password ? { "disable-auth": true } : {}), ...settingsOptions, }, }, diff --git a/src/backend/homepage.ts b/src/backend/homepage.ts new file mode 100644 index 00000000..01afd6c4 --- /dev/null +++ b/src/backend/homepage.ts @@ -0,0 +1,35 @@ +import express from "express"; +import cookieParser from "cookie-parser"; +import { createCorsMiddleware } from "./utils/cors-config.js"; +import { AuthManager } from "./utils/auth-manager.js"; +import { homepageItemsRouter } from "./database/routes/homepage-items-routes.js"; +import { homepageLayoutRouter } from "./database/routes/homepage-layout-routes.js"; +import { homepageFaviconRouter } from "./database/routes/homepage-favicon-routes.js"; +import { homepageRssRouter } from "./database/routes/homepage-rss-routes.js"; +import { homepagePingRouter } from "./database/routes/homepage-ping-routes.js"; +import { homepageProxyRouter } from "./database/routes/homepage-proxy-routes.js"; + +const app = express(); +const authManager = AuthManager.getInstance(); +const PORT = 30012; + +app.use(createCorsMiddleware()); +app.use(cookieParser()); +app.use(express.json({ limit: "1mb" })); +app.use((_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); + +app.use(authManager.createAuthMiddleware()); + +app.use("/homepage/items", homepageItemsRouter); +app.use("/homepage/layout", homepageLayoutRouter); +app.use("/homepage/favicon", homepageFaviconRouter); +app.use("/homepage/rss", homepageRssRouter); +app.use("/homepage/ping", homepagePingRouter); +app.use("/homepage/proxy", homepageProxyRouter); + +app.listen(PORT, "127.0.0.1", () => {}); + +export default app; diff --git a/src/backend/serial/serial.ts b/src/backend/serial/serial.ts new file mode 100644 index 00000000..f491c068 --- /dev/null +++ b/src/backend/serial/serial.ts @@ -0,0 +1,204 @@ +import { WebSocketServer, WebSocket, type RawData } from "ws"; +import { SerialPort } from "serialport"; +import { AuthManager } from "../utils/auth-manager.js"; +import { UserCrypto } from "../utils/user-crypto.js"; +import { sshLogger } from "../utils/logger.js"; + +interface SerialConnectData { + path: string; + baudRate: number; + dataBits?: 5 | 6 | 7 | 8; + stopBits?: 1 | 2; + parity?: "none" | "even" | "odd"; +} + +interface WebSocketMessage { + type: string; + data?: SerialConnectData | string | unknown; +} + +const authManager = AuthManager.getInstance(); +const userCrypto = UserCrypto.getInstance(); + +const wss = new WebSocketServer({ port: 30011 }); + +wss.on("connection", async (ws: WebSocket, req) => { + let userId: string | undefined; + + try { + let token: string | undefined; + + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); + if (match) token = decodeURIComponent(match[1]); + } + + if (!token) { + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.slice("Bearer ".length); + } + } + + if (!token) { + const urlObj = new URL(req.url || "", "http://localhost"); + const qp = urlObj.searchParams.get("token"); + if (qp) token = qp; + } + + if (!token) { + ws.close(1008, "Authentication required"); + return; + } + + const payload = await authManager.verifyJWTToken(token); + if (!payload?.userId || payload.pendingTOTP) { + ws.close(1008, "Authentication required"); + return; + } + + userId = payload.userId; + } catch { + ws.close(1008, "Authentication required"); + return; + } + + const dataKey = userCrypto.getUserDataKey(userId); + if (!dataKey) { + ws.send(JSON.stringify({ type: "error", data: "Data locked" })); + ws.close(1008, "Data access required"); + return; + } + + let port: SerialPort | null = null; + + const send = (msg: object) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } + }; + + const cleanup = () => { + if (port?.isOpen) { + port.close(); + } + port = null; + }; + + ws.on("message", async (raw: RawData) => { + let parsed: WebSocketMessage; + try { + parsed = JSON.parse(raw.toString()) as WebSocketMessage; + } catch { + return; + } + + const { type, data } = parsed; + + switch (type) { + case "list_ports": { + try { + const ports = await SerialPort.list(); + send({ type: "ports_list", data: ports }); + } catch (err) { + send({ + type: "error", + data: err instanceof Error ? err.message : "Failed to list ports", + }); + } + break; + } + + case "connect": { + if (port?.isOpen) { + port.close(); + port = null; + } + + const cfg = data as SerialConnectData; + if (!cfg?.path || !cfg?.baudRate) { + send({ type: "error", data: "Missing port path or baud rate" }); + break; + } + + try { + port = new SerialPort({ + path: cfg.path, + baudRate: cfg.baudRate, + dataBits: cfg.dataBits ?? 8, + stopBits: cfg.stopBits ?? 1, + parity: cfg.parity ?? "none", + autoOpen: false, + }); + + port.open((err) => { + if (err) { + sshLogger.error("Serial port open failed", err, { + operation: "serial_open", + path: cfg.path, + userId, + }); + send({ type: "error", data: err.message }); + port = null; + return; + } + sshLogger.info("Serial port opened", { + operation: "serial_open", + path: cfg.path, + baudRate: cfg.baudRate, + userId, + }); + send({ type: "connected" }); + }); + + port.on("data", (chunk: Buffer) => { + send({ type: "data", data: chunk.toString("binary") }); + }); + + port.on("error", (err) => { + send({ type: "error", data: err.message }); + }); + + port.on("close", () => { + send({ type: "disconnected" }); + port = null; + }); + } catch (err) { + send({ + type: "error", + data: + err instanceof Error ? err.message : "Failed to open serial port", + }); + } + break; + } + + case "input": { + if (!port?.isOpen) break; + const input = typeof data === "string" ? data : ""; + if (!input) break; + port.write(Buffer.from(input, "binary"), (err) => { + if (err) { + send({ type: "error", data: err.message }); + } + }); + break; + } + + case "disconnect": { + cleanup(); + send({ type: "disconnected" }); + break; + } + } + }); + + ws.on("close", () => { + cleanup(); + }); + + ws.on("error", () => { + cleanup(); + }); +}); diff --git a/src/backend/ssh/alert-engine.ts b/src/backend/ssh/alert-engine.ts new file mode 100644 index 00000000..2f1dfb73 --- /dev/null +++ b/src/backend/ssh/alert-engine.ts @@ -0,0 +1,380 @@ +import { getDb } from "../database/db/index.js"; +import { statsLogger } from "../utils/logger.js"; +import { + sendNotification, + type AlertPayload, + type NotificationChannel, +} from "../utils/notification-sender.js"; + +type AlertTriggerType = + | "host_offline" + | "host_online" + | "cpu_threshold" + | "memory_threshold" + | "disk_threshold" + | "health_check_failure" + | "health_check_recovery" + | "user_login"; + +interface AlertRule { + id: number; + userId: string; + hostId: number | null; + name: string; + enabled: boolean; + triggerType: AlertTriggerType; + thresholdValue: number | null; + thresholdDurationSeconds: number | null; + cooldownMinutes: number; +} + +interface RawRule { + id: number; + user_id: string; + host_id: number | null; + name: string; + enabled: number; + trigger_type: string; + threshold_value: number | null; + threshold_duration_seconds: number | null; + cooldown_minutes: number; +} + +interface RawChannel { + id: number; + type: string; + config: string; + enabled: number; +} + +export class AlertEngine { + private static instance: AlertEngine; + + // "${ruleId}:${hostId}" → lastFiredAt ms + private cooldownMap = new Map(); + // "${ruleId}:${hostId}" → breachStartTime ms + private breachStartMap = new Map(); + // hostId → last known status + private lastStatusMap = new Map(); + // "${hostId}:${checkId}" → last known ok state + private healthCheckStateMap = new Map(); + + static getInstance(): AlertEngine { + if (!AlertEngine.instance) { + AlertEngine.instance = new AlertEngine(); + } + return AlertEngine.instance; + } + + async evaluateMetrics( + hostId: number, + metrics: { + cpu?: { percent: number | null } | null; + memory?: { percent: number | null } | null; + disk?: { percent: number | null } | null; + }, + ): Promise { + const rules = this.loadRulesForHost(hostId).filter((r) => + ["cpu_threshold", "memory_threshold", "disk_threshold"].includes( + r.triggerType, + ), + ); + + if (rules.length === 0) return; + + const now = Date.now(); + + for (const rule of rules) { + let currentValue: number | null | undefined; + if (rule.triggerType === "cpu_threshold") + currentValue = metrics.cpu?.percent; + else if (rule.triggerType === "memory_threshold") + currentValue = metrics.memory?.percent; + else if (rule.triggerType === "disk_threshold") + currentValue = metrics.disk?.percent; + + if (currentValue == null || rule.thresholdValue == null) continue; + + const key = `${rule.id}:${hostId}`; + const isBreaching = currentValue >= rule.thresholdValue; + + if (isBreaching) { + if (!this.breachStartMap.has(key)) { + this.breachStartMap.set(key, now); + } + const breachStart = this.breachStartMap.get(key)!; + const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000; + if ( + now - breachStart >= durationMs && + !this.isCoolingDown(rule.id, hostId) + ) { + const hostName = this.getHostName(hostId); + await this.fireAlert(rule, hostId, hostName, { + value: currentValue, + message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`, + severity: currentValue >= 95 ? "critical" : "warning", + }); + } + } else { + this.breachStartMap.delete(key); + } + } + } + + async evaluateStatus(hostId: number, isOnline: boolean): Promise { + const currentStatus = isOnline ? "online" : "offline"; + const lastStatus = this.lastStatusMap.get(hostId); + + if (lastStatus === currentStatus) return; + this.lastStatusMap.set(hostId, currentStatus); + + if (lastStatus === undefined) return; + + const triggerType: AlertTriggerType = isOnline + ? "host_online" + : "host_offline"; + const rules = this.loadRulesForHost(hostId).filter( + (r) => r.triggerType === triggerType, + ); + + for (const rule of rules) { + if (!this.isCoolingDown(rule.id, hostId)) { + const hostName = this.getHostName(hostId); + await this.fireAlert(rule, hostId, hostName, { + message: isOnline + ? `Host "${hostName}" is back online` + : `Host "${hostName}" is offline`, + severity: isOnline ? "info" : "critical", + }); + } + } + } + + async evaluateHealthCheck( + hostId: number, + userId: string, + checkId: string, + ok: boolean, + detail?: string, + ): Promise { + const stateKey = `${hostId}:${checkId}`; + const lastOk = this.healthCheckStateMap.get(stateKey); + this.healthCheckStateMap.set(stateKey, ok); + + if (lastOk === undefined) return; + + let triggerType: AlertTriggerType | null = null; + if (!ok && lastOk) triggerType = "health_check_failure"; + else if (ok && !lastOk) triggerType = "health_check_recovery"; + + if (!triggerType) return; + + const rules = this.loadRulesForHostUser(hostId, userId).filter( + (r) => r.triggerType === triggerType, + ); + + for (const rule of rules) { + if (!this.isCoolingDown(rule.id, hostId)) { + const hostName = this.getHostName(hostId); + await this.fireAlert(rule, hostId, hostName, { + message: ok + ? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}` + : `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`, + severity: ok ? "info" : "warning", + }); + } + } + } + + async evaluateUserLogin( + hostId: number, + userId: string, + sshUser: string, + fromIp: string, + ): Promise { + const rules = this.loadRulesForHostUser(hostId, userId).filter( + (r) => r.triggerType === "user_login", + ); + + for (const rule of rules) { + if (!this.isCoolingDown(rule.id, hostId)) { + const hostName = this.getHostName(hostId); + await this.fireAlert(rule, hostId, hostName, { + message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`, + severity: "info", + }); + } + } + } + + private async fireAlert( + rule: AlertRule, + hostId: number, + hostName: string, + context: { + value?: number; + message: string; + severity: "info" | "warning" | "critical"; + }, + ): Promise { + this.markCooldown(rule.id, hostId); + + const payload: AlertPayload = { + hostName, + hostId, + triggerType: rule.triggerType, + value: context.value, + threshold: rule.thresholdValue ?? undefined, + message: context.message, + severity: context.severity, + timestamp: new Date().toISOString(), + ruleId: rule.id, + ruleName: rule.name, + }; + + try { + const db = getDb(); + db.$client + .prepare( + `INSERT INTO alert_firings (user_id, rule_id, host_id, host_name, value, message, severity) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + rule.userId, + rule.id, + hostId, + hostName, + context.value ?? null, + context.message, + context.severity, + ); + + // Prune old firings beyond 30 days + db.$client + .prepare( + `DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', '-30 days')`, + ) + .run(rule.userId); + } catch (err) { + statsLogger.warn("Failed to write alert firing", { + operation: "alert_firing_insert_error", + ruleId: rule.id, + error: err instanceof Error ? err.message : String(err), + }); + } + + const channels = this.loadChannelsForRule(rule.id); + for (const channel of channels) { + sendNotification(channel, payload).catch((err) => { + statsLogger.warn("Failed to send notification", { + operation: "notification_delivery_error", + channelId: channel.id, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + } + + private isCoolingDown(ruleId: number, hostId: number): boolean { + const key = `${ruleId}:${hostId}`; + const lastFired = this.cooldownMap.get(key); + if (!lastFired) return false; + const rule = this.loadRuleById(ruleId); + const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000; + return Date.now() - lastFired < cooldownMs; + } + + private markCooldown(ruleId: number, hostId: number): void { + this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now()); + } + + private loadRulesForHost(hostId: number): AlertRule[] { + try { + const db = getDb(); + const rows = db.$client + .prepare( + `SELECT * FROM alert_rules + WHERE enabled = 1 AND (host_id = ? OR host_id IS NULL)`, + ) + .all(hostId) as RawRule[]; + return rows.map(mapRawRule); + } catch { + return []; + } + } + + private loadRulesForHostUser(hostId: number, userId: string): AlertRule[] { + try { + const db = getDb(); + const rows = db.$client + .prepare( + `SELECT * FROM alert_rules + WHERE enabled = 1 AND user_id = ? AND (host_id = ? OR host_id IS NULL)`, + ) + .all(userId, hostId) as RawRule[]; + return rows.map(mapRawRule); + } catch { + return []; + } + } + + private loadRuleById(ruleId: number): AlertRule | null { + try { + const db = getDb(); + const row = db.$client + .prepare("SELECT * FROM alert_rules WHERE id = ?") + .get(ruleId) as RawRule | undefined; + return row ? mapRawRule(row) : null; + } catch { + return null; + } + } + + private loadChannelsForRule(ruleId: number): NotificationChannel[] { + try { + const db = getDb(); + const rows = db.$client + .prepare( + `SELECT nc.id, nc.type, nc.config, nc.enabled + FROM notification_channels nc + JOIN alert_rule_channels arc ON arc.channel_id = nc.id + WHERE arc.rule_id = ? AND nc.enabled = 1`, + ) + .all(ruleId) as RawChannel[]; + return rows.map((r) => ({ + id: r.id, + type: r.type, + config: r.config, + enabled: r.enabled === 1, + })); + } catch { + return []; + } + } + + private getHostName(hostId: number): string { + try { + const db = getDb(); + const row = db.$client + .prepare("SELECT name, ip FROM ssh_data WHERE id = ?") + .get(hostId) as { name: string | null; ip: string } | undefined; + return row?.name || row?.ip || `Host #${hostId}`; + } catch { + return `Host #${hostId}`; + } + } +} + +function mapRawRule(r: RawRule): AlertRule { + return { + id: r.id, + userId: r.user_id, + hostId: r.host_id, + name: r.name, + enabled: r.enabled === 1, + triggerType: r.trigger_type as AlertTriggerType, + thresholdValue: r.threshold_value, + thresholdDurationSeconds: r.threshold_duration_seconds, + cooldownMinutes: r.cooldown_minutes, + }; +} diff --git a/src/backend/ssh/container-runtime.ts b/src/backend/ssh/container-runtime.ts new file mode 100644 index 00000000..9883ec77 --- /dev/null +++ b/src/backend/ssh/container-runtime.ts @@ -0,0 +1,43 @@ +export type ContainerRuntime = "docker" | "podman"; + +export function normalizeContainerRuntime(value: unknown): ContainerRuntime { + return value === "podman" ? "podman" : "docker"; +} + +export function getContainerRuntimeConfig(raw: unknown): { + runtime: ContainerRuntime; +} { + if (!raw) { + return { runtime: "docker" }; + } + + let config: unknown = raw; + if (typeof raw === "string") { + try { + config = JSON.parse(raw) as Record; + } catch { + return { runtime: "docker" }; + } + } + + if (!config || typeof config !== "object") { + return { runtime: "docker" }; + } + + return { + runtime: normalizeContainerRuntime( + (config as Record).runtime, + ), + }; +} + +export function containerCommand( + runtime: ContainerRuntime | undefined, + args: string, +): string { + return `${normalizeContainerRuntime(runtime)} ${args}`; +} + +export function getRuntimeLabel(runtime: ContainerRuntime): string { + return runtime === "podman" ? "Podman" : "Docker"; +} diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts index 2af5dc14..b6e80845 100644 --- a/src/backend/ssh/docker-console.ts +++ b/src/backend/ssh/docker-console.ts @@ -8,6 +8,12 @@ import { getDb } from "../database/db/index.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { systemLogger } from "../utils/logger.js"; import type { SSHHost } from "../../types/index.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { + containerCommand, + getContainerRuntimeConfig, + type ContainerRuntime, +} from "./container-runtime.js"; const sshLogger = systemLogger; @@ -18,6 +24,7 @@ interface SSHSession { containerId?: string; shell?: string; hostId?: number; + containerRuntime?: ContainerRuntime; } const activeSessions = new Map(); @@ -37,7 +44,10 @@ async function detectShell( try { await new Promise((resolve, reject) => { session.client.exec( - `docker exec ${containerId} which ${shell}`, + containerCommand( + session.containerRuntime, + `exec ${containerId} which ${shell}`, + ), (err, stream) => { if (err) return reject(err); @@ -219,6 +229,16 @@ async function createJumpHostChain( if (resolvedCredentials.keyPassword) { config.passphrase = resolvedCredentials.keyPassword; } + } else if (resolvedCredentials.authType === "agent") { + const result = await applyAgentAuth( + config, + jumpHost.terminalConfig as unknown as + | Record + | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } client.on( @@ -427,6 +447,22 @@ wss.on("connection", async (ws: WebSocket, req) => { if (resolvedHost.keyPassword) { config.passphrase = resolvedHost.keyPassword; } + } else if (resolvedHost.authType === "agent") { + const result = await applyAgentAuth( + config, + resolvedHost.terminalConfig as unknown as + | Record + | undefined, + ); + if ("error" in result) { + ws.send( + JSON.stringify({ + type: "error", + message: result.error, + }), + ); + return; + } } if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) { @@ -459,12 +495,17 @@ wss.on("connection", async (ws: WebSocket, req) => { client.connect(config); }); + const { runtime: containerRuntime } = getContainerRuntimeConfig( + resolvedHost.dockerConfig, + ); + sshSession = { client, stream: null, isConnected: true, containerId, hostId: resolvedHost.id, + containerRuntime, }; activeSessions.set(sessionId, sshSession); @@ -475,7 +516,10 @@ wss.on("connection", async (ws: WebSocket, req) => { try { await new Promise((resolve, reject) => { client.exec( - `docker exec ${containerId} which ${shell}`, + containerCommand( + containerRuntime, + `exec ${containerId} which ${shell}`, + ), (err, stream) => { if (err) return reject(err); @@ -518,7 +562,10 @@ wss.on("connection", async (ws: WebSocket, req) => { sshSession.shell = shellToUse; - const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`; + const execCommand = containerCommand( + containerRuntime, + `exec -it ${containerId} /bin/${shellToUse}`, + ); sshLogger.info("Attaching to Docker container", { operation: "docker_attach", sessionId, diff --git a/src/backend/ssh/docker-container-routes.ts b/src/backend/ssh/docker-container-routes.ts index d6c8dc8d..9bb115ab 100644 --- a/src/backend/ssh/docker-container-routes.ts +++ b/src/backend/ssh/docker-container-routes.ts @@ -1,5 +1,9 @@ import type express from "express"; import { logger } from "../utils/logger.js"; +import { + containerCommand, + type ContainerRuntime, +} from "./container-runtime.js"; const sshLogger = logger; @@ -9,6 +13,7 @@ type DockerSession = { activeOperations: number; hostId?: number; isWindows?: boolean; + containerRuntime?: ContainerRuntime; }; type PendingDockerTotpSession = unknown; @@ -39,6 +44,9 @@ export function registerDockerContainerRoutes( dockerTimestampPattern: DOCKER_TIMESTAMP_RE, }: DockerContainerRoutesDeps, ): void { + const getRuntime = (session: DockerSession) => + session.containerRuntime ?? "docker"; + /** * @openapi * /docker/containers/{sessionId}: @@ -97,7 +105,10 @@ export function registerDockerContainerRoutes( const formatStr = session.isWindows ? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"` : `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `; - const command = `docker ps ${allFlag}--format ${formatStr}`; + const command = containerCommand( + getRuntime(session), + `ps ${allFlag}--format ${formatStr}`, + ); const output = await executeDockerCommand( session, @@ -190,7 +201,10 @@ export function registerDockerContainerRoutes( session.activeOperations++; try { - const command = `docker inspect ${containerId}`; + const command = containerCommand( + getRuntime(session), + `inspect ${containerId}`, + ); const output = await executeDockerCommand( session, command, @@ -295,7 +309,7 @@ export function registerDockerContainerRoutes( }); await executeDockerCommand( session, - `docker start ${containerId}`, + containerCommand(getRuntime(session), `start ${containerId}`), sessionId, userId, session.hostId, @@ -395,7 +409,7 @@ export function registerDockerContainerRoutes( }); await executeDockerCommand( session, - `docker stop ${containerId}`, + containerCommand(getRuntime(session), `stop ${containerId}`), sessionId, userId, session.hostId, @@ -495,7 +509,7 @@ export function registerDockerContainerRoutes( }); await executeDockerCommand( session, - `docker restart ${containerId}`, + containerCommand(getRuntime(session), `restart ${containerId}`), sessionId, userId, session.hostId, @@ -595,7 +609,7 @@ export function registerDockerContainerRoutes( }); await executeDockerCommand( session, - `docker pause ${containerId}`, + containerCommand(getRuntime(session), `pause ${containerId}`), sessionId, userId, session.hostId, @@ -695,7 +709,7 @@ export function registerDockerContainerRoutes( }); await executeDockerCommand( session, - `docker unpause ${containerId}`, + containerCommand(getRuntime(session), `unpause ${containerId}`), sessionId, userId, session.hostId, @@ -801,7 +815,10 @@ export function registerDockerContainerRoutes( const forceFlag = force ? "-f " : ""; await executeDockerCommand( session, - `docker rm ${forceFlag}${containerId}`, + containerCommand( + getRuntime(session), + `rm ${forceFlag}${containerId}`, + ), sessionId, userId, session.hostId, @@ -920,7 +937,10 @@ export function registerDockerContainerRoutes( session.activeOperations++; try { - let command = `docker logs ${containerId}`; + let command = containerCommand( + getRuntime(session), + `logs ${containerId}`, + ); if (tail && tail > 0) { command += ` --tail ${Math.floor(tail)}`; @@ -1035,7 +1055,10 @@ export function registerDockerContainerRoutes( const statsFormatStr = session.isWindows ? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"` : `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `; - const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`; + const command = containerCommand( + getRuntime(session), + `stats ${containerId} --no-stream --format ${statsFormatStr}`, + ); const output = await executeDockerCommand( session, diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index 8523e620..a3cefe33 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -19,6 +19,15 @@ import type { SSHHost, ProxyNode } from "../../types/index.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { registerDockerContainerRoutes } from "./docker-container-routes.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; +import { + containerCommand, + getContainerRuntimeConfig, + getRuntimeLabel, + type ContainerRuntime, +} from "./container-runtime.js"; const sshLogger = logger; @@ -45,6 +54,7 @@ interface SSHSession { hostId?: number; userId?: string; isWindows?: boolean; + containerRuntime?: ContainerRuntime; } interface PendingTOTPSession { @@ -63,6 +73,7 @@ interface PendingTOTPSession { resolvedPassword?: string; totpAttempts: number; isWarpgate?: boolean; + containerRuntime?: ContainerRuntime; } const sshSessions: Record = {}; @@ -133,6 +144,12 @@ interface JumpHostConfig { keyType?: string; authType?: string; credentialId?: number; + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; [key: string]: unknown; } @@ -253,13 +270,17 @@ async function createJumpHostChain( } } + const firstHopSocks5Config = getJumpHostSocks5Config( + jumpHostConfigs[0], + socks5Config, + ); let proxySocket: import("net").Socket | null = null; - if (socks5Config?.useSocks5) { + if (firstHopSocks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, - socks5Config, + firstHopSocks5Config, ); } @@ -278,7 +299,8 @@ async function createJumpHostChain( true, ); - const connected = await new Promise((resolve) => { + // eslint-disable-next-line no-async-promise-executor + const connected = await new Promise(async (resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); @@ -371,6 +393,16 @@ async function createJumpHostChain( if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } + } else if (jumpHostConfig.authType === "agent") { + const result = await applyAgentAuth( + connectConfig, + jumpHostConfig.terminalConfig as + | Record + | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } jumpClient.on( @@ -700,6 +732,9 @@ app.post("/docker/ssh/connect", async (req, res) => { host.terminalConfig = undefined; } } + const { runtime: containerRuntime } = getContainerRuntimeConfig( + host.dockerConfig, + ); if (!host.enableDocker) { sshLogger.warn("Docker not enabled for host", { @@ -923,37 +958,16 @@ app.post("/docker/ssh/connect", async (req, res) => { resolvedCredentials.sshKey ) { try { - if ( - !resolvedCredentials.sshKey.includes("-----BEGIN") || - !resolvedCredentials.sshKey.includes("-----END") - ) { - sshLogger.error("Invalid SSH key format", { - operation: "docker_connect", - sessionId, - hostId, - }); - connectionLogs.push( - createConnectionLog( - "error", - "docker_auth", - "Invalid SSH private key format", - ), - ); - return res.status(400).json({ - error: "Invalid private key format", - connectionLogs, - }); - } - - const cleanKey = resolvedCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - config.privateKey = Buffer.from(cleanKey, "utf8"); + config.privateKey = preparePrivateKeyForSSH2( + resolvedCredentials.sshKey, + resolvedCredentials.keyPassword, + ); if (resolvedCredentials.keyPassword) { config.passphrase = resolvedCredentials.keyPassword; } } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid private key format"; sshLogger.error("SSH key processing error", error, { operation: "docker_connect", sessionId, @@ -963,11 +977,11 @@ app.post("/docker/ssh/connect", async (req, res) => { createConnectionLog( "error", "docker_auth", - "SSH key processing error", + `SSH key processing error: ${message}`, ), ); return res.status(400).json({ - error: "SSH key format error: Invalid private key format", + error: `SSH key format error: ${message}`, connectionLogs, }); } @@ -988,6 +1002,24 @@ app.post("/docker/ssh/connect", async (req, res) => { error: "SSH key authentication requested but no key provided", connectionLogs, }); + } else if (resolvedCredentials.authType === "agent") { + const result = await applyAgentAuth( + config, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + connectionLogs.push( + createConnectionLog("error", "docker_auth", result.error), + ); + return res.status(400).json({ error: result.error, connectionLogs }); + } + connectionLogs.push( + createConnectionLog( + "info", + "docker_auth", + "Using SSH agent authentication", + ), + ); } let responseSent = false; @@ -1015,6 +1047,10 @@ app.post("/docker/ssh/connect", async (req, res) => { connectionLogs.push( createConnectionLog("info", "auth", "Authenticating with SSH key"), ); + } else if (resolvedCredentials.authType === "agent") { + connectionLogs.push( + createConnectionLog("info", "auth", "Authenticating with SSH agent"), + ); } else if ( resolvedCredentials.authType === "none" || resolvedCredentials.authType === "tailscale" @@ -1047,6 +1083,7 @@ app.post("/docker/ssh/connect", async (req, res) => { activeOperations: 0, hostId, userId, + containerRuntime, }; sshSessions[sessionId] = session; @@ -1244,6 +1281,7 @@ app.post("/docker/ssh/connect", async (req, res) => { resolvedPassword: resolvedCredentials.password, totpAttempts: 0, isWarpgate: true, + containerRuntime, }; connectionLogs.push( @@ -1310,6 +1348,7 @@ app.post("/docker/ssh/connect", async (req, res) => { totpPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, + containerRuntime, }; connectionLogs.push( @@ -1400,6 +1439,7 @@ app.post("/docker/ssh/connect", async (req, res) => { totpPromptIndex: passwordPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, + containerRuntime, }; res.json({ @@ -1753,6 +1793,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { activeOperations: 0, hostId: session.hostId, userId, + containerRuntime: session.containerRuntime, }; scheduleSessionCleanup(sessionId); @@ -1939,6 +1980,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => { activeOperations: 0, hostId: session.hostId, userId, + containerRuntime: session.containerRuntime, }; scheduleSessionCleanup(sessionId); @@ -2154,20 +2196,24 @@ app.get("/docker/validate/:sessionId", async (req, res) => { try { try { + const runtime = session.containerRuntime ?? "docker"; + const runtimeLabel = getRuntimeLabel(runtime); const versionOutput = await executeDockerCommand( session, - "docker --version", + containerCommand(runtime, "--version"), sessionId, userId, session.hostId, ); - const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/); + const versionMatch = versionOutput.match( + /(?:Docker|podman) version ([^\s,]+)/i, + ); const version = versionMatch ? versionMatch[1] : "unknown"; try { await executeDockerCommand( session, - "docker ps", + containerCommand(runtime, "ps"), sessionId, userId, session.hostId, @@ -2177,6 +2223,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => { return res.json({ available: true, version, + runtime, }); } catch (daemonError) { session.activeOperations--; @@ -2186,18 +2233,18 @@ app.get("/docker/validate/:sessionId", async (req, res) => { if (errorMsg.includes("Cannot connect to the Docker daemon")) { return res.json({ available: false, - error: - "Docker daemon is not running. Start it with: sudo systemctl start docker", + error: `${runtimeLabel} daemon is not running or accessible`, code: "DAEMON_NOT_RUNNING", + runtime, }); } if (errorMsg.includes("permission denied")) { return res.json({ available: false, - error: - "Permission denied. Add your user to the docker group: sudo usermod -aG docker $USER", + error: `Permission denied accessing ${runtimeLabel}`, code: "PERMISSION_DENIED", + runtime, }); } @@ -2205,15 +2252,18 @@ app.get("/docker/validate/:sessionId", async (req, res) => { available: false, error: errorMsg, code: "DOCKER_ERROR", + runtime, }); } } catch { session.activeOperations--; + const runtime = session.containerRuntime ?? "docker"; + const runtimeLabel = getRuntimeLabel(runtime); return res.json({ available: false, - error: - "Docker is not installed on this host. Please install Docker to use this feature.", + error: `${runtimeLabel} is not installed on this host.`, code: "NOT_INSTALLED", + runtime, }); } } catch (error) { diff --git a/src/backend/ssh/file-manager-content-routes.ts b/src/backend/ssh/file-manager-content-routes.ts index 6d12ed4f..853c17d5 100644 --- a/src/backend/ssh/file-manager-content-routes.ts +++ b/src/backend/ssh/file-manager-content-routes.ts @@ -1507,4 +1507,269 @@ export function registerFileContentRoutes( req.pipe(bb); }, ); + + /** + * @openapi + * /ssh/file_manager/ssh/uploadFileChunk: + * post: + * summary: Upload a single chunk of a large file + * description: Receives one chunk of a large file upload via multipart form data and either creates a new remote file (chunkIndex=0) or appends to the existing file (chunkIndex>0). Used by the client to upload files larger than ~2GB that would otherwise hit browser-side ArrayBuffer limits. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - sessionId + * - path + * - fileName + * - chunkIndex + * - totalChunks + * - totalSize + * - chunk + * properties: + * sessionId: + * type: string + * path: + * type: string + * fileName: + * type: string + * chunkIndex: + * type: integer + * totalChunks: + * type: integer + * totalSize: + * type: integer + * chunk: + * type: string + * format: binary + * responses: + * 200: + * description: Chunk accepted. When chunkIndex === totalChunks - 1 the full file is complete. + * 400: + * description: Missing required parameters, invalid chunk metadata, or SSH connection not established. + * 403: + * description: Session access denied. + * 500: + * description: Failed to write chunk to remote filesystem. + */ + app.post( + "/ssh/file_manager/ssh/uploadFileChunk", + (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + const contentType = req.headers["content-type"] || ""; + if (!contentType.includes("multipart/form-data")) { + return res + .status(400) + .json({ error: "Expected multipart/form-data request" }); + } + + let sessionId: string | undefined; + let remotePath: string | undefined; + let fileName: string | undefined; + let chunkIndex = -1; + let totalChunks = 0; + let totalSize = 0; + let resolved = false; + let chunkStartTime = Date.now(); + + const bb = Busboy({ + headers: req.headers, + limits: { + fileSize: 32 * 1024 * 1024, + }, + }); + + bb.on("field", (name: string, value: string) => { + if (name === "sessionId") sessionId = value; + else if (name === "path") remotePath = value; + else if (name === "fileName") fileName = value; + else if (name === "chunkIndex") { + const n = Number.parseInt(value, 10); + if (Number.isFinite(n) && n >= 0) chunkIndex = n; + } else if (name === "totalChunks") { + const n = Number.parseInt(value, 10); + if (Number.isFinite(n) && n > 0) totalChunks = n; + } else if (name === "totalSize") { + const n = Number.parseInt(value, 10); + if (Number.isFinite(n) && n >= 0) totalSize = n; + } + }); + + bb.on( + "file", + ( + fieldname: string, + fileStream: NodeJS.ReadableStream, + _info: { filename: string; encoding: string; mimeType: string }, + ) => { + if ( + !sessionId || + !remotePath || + !fileName || + chunkIndex < 0 || + totalChunks <= 0 || + chunkIndex >= totalChunks + ) { + fileStream.resume(); + if (!resolved) { + resolved = true; + res.status(400).json({ + error: + "Missing or invalid chunk metadata (sessionId, path, fileName, chunkIndex, totalChunks required)", + }); + } + return; + } + + const sshConn = sshSessions[sessionId]; + if (!sshConn?.isConnected) { + fileStream.resume(); + if (!resolved) { + resolved = true; + res.status(400).json({ error: "SSH connection not established" }); + } + return; + } + + if (!verifySessionOwnership(sshConn, userId)) { + fileStream.resume(); + if (!resolved) { + resolved = true; + res.status(403).json({ error: "Session access denied" }); + } + return; + } + + sshConn.lastActive = Date.now(); + chunkStartTime = Date.now(); + + const fullPath = remotePath.endsWith("/") + ? remotePath + fileName + : remotePath + "/" + fileName; + + const flags = chunkIndex === 0 ? "w" : "a"; + + fileLogger.info("Chunk upload started", { + operation: "file_upload_chunk_start", + sessionId, + userId, + path: fullPath, + chunkIndex, + totalChunks, + totalSize, + }); + + getSessionSftp(sshConn) + .then((sftp) => { + const writeStream = sftp.createWriteStream(fullPath, { + flags, + }); + + let chunkBytes = 0; + + fileStream.on("data", (data: Buffer) => { + chunkBytes += data.length; + }); + + writeStream.on("error", (err) => { + fileLogger.error( + "SFTP write stream error during chunk upload:", + err, + ); + fileStream.resume(); + if (!resolved) { + resolved = true; + res + .status(500) + .json({ error: `Chunk upload failed: ${err.message}` }); + } + }); + + writeStream.on("finish", () => { + if (resolved) return; + resolved = true; + const isFinalChunk = chunkIndex === totalChunks - 1; + fileLogger.success( + isFinalChunk + ? "Chunked file upload completed" + : "Chunk upload completed", + { + operation: isFinalChunk + ? "file_upload_chunked_complete" + : "file_upload_chunk_complete", + sessionId, + userId, + path: fullPath, + chunkIndex, + totalChunks, + chunkBytes, + totalSize, + duration: Date.now() - chunkStartTime, + }, + ); + res.json({ + message: isFinalChunk + ? "File uploaded successfully" + : "Chunk accepted", + path: fullPath, + chunkIndex, + totalChunks, + chunkBytes, + complete: isFinalChunk, + toast: isFinalChunk + ? { + type: "success", + message: `File uploaded: ${fullPath}`, + } + : undefined, + }); + }); + + fileStream.on("error", (err) => { + fileLogger.error( + "File read stream error during chunk upload:", + err, + ); + writeStream.destroy(); + if (!resolved) { + resolved = true; + res.status(500).json({ + error: `Chunk upload stream error: ${err.message}`, + }); + } + }); + + (fileStream as NodeJS.ReadableStream).pipe( + writeStream as unknown as NodeJS.WritableStream, + ); + }) + .catch((err: Error) => { + fileStream.resume(); + fileLogger.error("SFTP session error during chunk upload:", err); + if (!resolved) { + resolved = true; + res.status(500).json({ error: `SFTP error: ${err.message}` }); + } + }); + }, + ); + + bb.on("error", (err: Error) => { + fileLogger.error("Busboy parse error during chunk upload:", err); + if (!resolved) { + resolved = true; + res + .status(500) + .json({ error: `Chunk upload parse error: ${err.message}` }); + } + }); + + req.pipe(bb); + }, + ); } diff --git a/src/backend/ssh/file-manager-list-routes.ts b/src/backend/ssh/file-manager-list-routes.ts index b095ce21..a279a63c 100644 --- a/src/backend/ssh/file-manager-list-routes.ts +++ b/src/backend/ssh/file-manager-list-routes.ts @@ -10,6 +10,7 @@ import { formatMtime, isExecutableFile, modeToPermissions, + parseLsDateToTimestamp, } from "./file-manager-utils.js"; type FileListingRoutesDeps = { @@ -111,6 +112,7 @@ export function registerFileListingRoutes( type: string; size: number | undefined; modified: string; + modifiedTimestamp: number; permissions: string; owner: string; group: string; @@ -132,6 +134,7 @@ export function registerFileListingRoutes( type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : attrs.size, modified: formatMtime(attrs.mtime), + modifiedTimestamp: attrs.mtime, permissions, owner: String(attrs.uid), group: String(attrs.gid), @@ -306,6 +309,7 @@ export function registerFileListingRoutes( type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : size, modified: dateStr, + modifiedTimestamp: parseLsDateToTimestamp(dateStr), permissions, owner, group, @@ -392,6 +396,7 @@ export function registerFileListingRoutes( type: string; size: number | undefined; modified: string; + modifiedTimestamp: number; permissions: string; owner: string; group: string; @@ -435,6 +440,7 @@ export function registerFileListingRoutes( type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : size, modified: dateStr, + modifiedTimestamp: parseLsDateToTimestamp(dateStr), permissions, owner, group, diff --git a/src/backend/ssh/file-manager-utils.test.ts b/src/backend/ssh/file-manager-utils.test.ts index 85eddb08..790dbc17 100644 --- a/src/backend/ssh/file-manager-utils.test.ts +++ b/src/backend/ssh/file-manager-utils.test.ts @@ -5,6 +5,7 @@ import { formatMtime, getMimeType, detectBinary, + parseLsDateToTimestamp, } from "./file-manager-utils.js"; describe("isExecutableFile", () => { @@ -84,6 +85,50 @@ describe("getMimeType", () => { }); }); +describe("parseLsDateToTimestamp", () => { + it("parses a year-format date string", () => { + const ts = parseLsDateToTimestamp("Dec 12 2025"); + const d = new Date(ts * 1000); + expect(d.getFullYear()).toBe(2025); + expect(d.getMonth()).toBe(11); + expect(d.getDate()).toBe(12); + }); + + it("parses a time-format date string for a recent file", () => { + const now = new Date(); + const month = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ][now.getMonth()]; + const day = now.getDate(); + const ts = parseLsDateToTimestamp(`${month} ${day} 10:30`); + const d = new Date(ts * 1000); + expect(d.getHours()).toBe(10); + expect(d.getMinutes()).toBe(30); + }); + + it("returns 0 for an empty or invalid string", () => { + expect(parseLsDateToTimestamp("")).toBe(0); + expect(parseLsDateToTimestamp("Xyz 5 12:00")).toBe(0); + }); + + it("produces ascending order for older vs newer dates", () => { + const older = parseLsDateToTimestamp("Jan 1 2020"); + const newer = parseLsDateToTimestamp("Dec 31 2024"); + expect(older).toBeLessThan(newer); + }); +}); + describe("detectBinary", () => { it("returns false for empty buffers", () => { expect(detectBinary(Buffer.from([]))).toBe(false); diff --git a/src/backend/ssh/file-manager-utils.ts b/src/backend/ssh/file-manager-utils.ts index b3809a18..5a0cf569 100644 --- a/src/backend/ssh/file-manager-utils.ts +++ b/src/backend/ssh/file-manager-utils.ts @@ -56,6 +56,43 @@ export function modeToPermissions(mode: number): string { return prefix + perms; } +export function parseLsDateToTimestamp(dateStr: string): number { + const parts = dateStr.trim().split(/\s+/); + if (parts.length < 3) return 0; + + const [month, day, timeOrYear] = parts; + const monthIndex = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ].indexOf(month); + if (monthIndex === -1) return 0; + + const dayNum = parseInt(day, 10); + const now = new Date(); + + if (timeOrYear.includes(":")) { + const [hours, minutes] = timeOrYear.split(":").map(Number); + let year = now.getFullYear(); + const candidate = new Date(year, monthIndex, dayNum, hours, minutes); + if (candidate > now) year -= 1; + return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000; + } + + const year = parseInt(timeOrYear, 10); + if (isNaN(year)) return 0; + return new Date(year, monthIndex, dayNum).getTime() / 1000; +} + export function formatMtime(mtime: number): string { const date = new Date(mtime * 1000); const months = [ diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 338049f1..1a799d9b 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -33,6 +33,7 @@ import { import { registerFileContentRoutes } from "./file-manager-content-routes.js"; import { createConnectionLog } from "./file-manager-log.js"; import { createJumpHostChain } from "./jump-host-chain.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { ChannelOpenSerializer, execChannel, @@ -44,6 +45,7 @@ import { registerFileListingRoutes } from "./file-manager-list-routes.js"; import { registerFileOperationRoutes } from "./file-manager-operation-routes.js"; import { registerFileDownloadRoutes } from "./file-manager-download-routes.js"; import { registerFileActionRoutes } from "./file-manager-action-routes.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; const app = express(); @@ -222,6 +224,14 @@ async function buildDedicatedTransferConnectConfig( token, username, ); + } else if (authType === "agent") { + const result = await applyAgentAuth( + config, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } else if (authType !== "none" && authType !== "tailscale") { throw new Error(`Unsupported auth type for transfer: ${authType}`); } @@ -727,6 +737,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { let resolvedIp = ip; let resolvedPort = port; let resolvedUsername = username; + let resolvedTerminalConfig: Record | undefined; let resolvedJumpHosts = jumpHosts; let resolvedScpLegacy = false; let resolvedUseSocks5 = useSocks5; @@ -750,6 +761,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { authType: resolvedHost.authType, sudoPassword: resolvedHost.sudoPassword as string | undefined, }; + resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as + | Record + | undefined; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; @@ -806,6 +820,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { authType: resolvedHost.authType, sudoPassword: resolvedHost.sudoPassword as string | undefined, }; + resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as + | Record + | undefined; hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval; hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax; resolvedScpLegacy = resolvedHost.scpLegacy ?? false; @@ -929,19 +946,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { resolvedCredentials.sshKey.trim() ) { try { - if ( - !resolvedCredentials.sshKey.includes("-----BEGIN") || - !resolvedCredentials.sshKey.includes("-----END") - ) { - throw new Error("Invalid private key format"); - } - - const cleanKey = resolvedCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - - config.privateKey = Buffer.from(cleanKey, "utf8"); + config.privateKey = preparePrivateKeyForSSH2( + resolvedCredentials.sshKey, + resolvedCredentials.keyPassword, + ); if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; @@ -1044,6 +1052,21 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { connectionLogs, }); } + } else if (resolvedCredentials.authType === "agent") { + const result = await applyAgentAuth(config, resolvedTerminalConfig); + if ("error" in result) { + connectionLogs.push( + createConnectionLog("error", "sftp_auth", result.error), + ); + return res.status(400).json({ error: result.error, connectionLogs }); + } + connectionLogs.push( + createConnectionLog( + "info", + "sftp_auth", + "Using SSH agent authentication", + ), + ); } else if ( resolvedCredentials.authType === "none" || resolvedCredentials.authType === "tailscale" || diff --git a/src/backend/ssh/host-metrics-helpers.test.ts b/src/backend/ssh/host-metrics-helpers.test.ts index f3523999..70714d9f 100644 --- a/src/backend/ssh/host-metrics-helpers.test.ts +++ b/src/backend/ssh/host-metrics-helpers.test.ts @@ -19,6 +19,7 @@ describe("supportsMetrics", () => { it("rejects non-ssh connection types", () => { expect(supportsMetrics({ connectionType: "rdp" })).toBe(false); expect(supportsMetrics({ connectionType: "vnc" })).toBe(false); + expect(supportsMetrics({ connectionType: "telnet" })).toBe(false); }); it("rejects ssh hosts that cannot run shell commands", () => { diff --git a/src/backend/ssh/host-metrics-history-routes.ts b/src/backend/ssh/host-metrics-history-routes.ts new file mode 100644 index 00000000..05caddef --- /dev/null +++ b/src/backend/ssh/host-metrics-history-routes.ts @@ -0,0 +1,125 @@ +import type { Express, RequestHandler } from "express"; +import type { AuthenticatedRequest } from "../../types/index.js"; +import { getDb } from "../database/db/index.js"; +import { statsLogger } from "../utils/logger.js"; + +type HistoryRoutesDeps = { + validateHostId: RequestHandler; + canAccessHost: ( + userId: string, + hostId: number, + level: "read" | "write" | "execute" | "delete" | "share", + ) => Promise; +}; + +const RANGE_OFFSETS: Record = { + "1h": 1 * 60 * 60 * 1000, + "6h": 6 * 60 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +export function registerHostMetricsHistoryRoutes( + app: Express, + { validateHostId, canAccessHost }: HistoryRoutesDeps, +): void { + /** + * @openapi + * /metrics/history/{id}: + * get: + * summary: Get historical metrics for a host + * tags: + * - Host Metrics + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: query + * name: range + * schema: + * type: string + * enum: [1h, 6h, 24h, 7d, 30d] + * - in: query + * name: from + * schema: + * type: string + * format: date-time + * - in: query + * name: to + * schema: + * type: string + * format: date-time + * responses: + * 200: + * description: Array of metric history rows. + * 403: + * description: Access denied. + * 404: + * description: Host not found or no access. + */ + app.get("/metrics/history/:id", validateHostId, async (req, res) => { + const hostId = Number(req.params.id); + const userId = (req as AuthenticatedRequest).userId; + + try { + const hasAccess = await canAccessHost(userId, hostId, "read"); + if (!hasAccess) { + return res.status(403).json({ error: "Access denied" }); + } + + const { range, from, to } = req.query as Record< + string, + string | undefined + >; + + let fromTs: string; + let toTs: string = new Date().toISOString(); + + if (range) { + const offsetMs = RANGE_OFFSETS[range]; + if (!offsetMs) { + return res + .status(400) + .json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" }); + } + fromTs = new Date(Date.now() - offsetMs).toISOString(); + } else if (from && to) { + const fromDate = new Date(from); + const toDate = new Date(to); + if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { + return res.status(400).json({ error: "Invalid from/to date format" }); + } + fromTs = fromDate.toISOString(); + toTs = toDate.toISOString(); + } else { + fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + } + + const db = getDb(); + // SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z). + // Normalize both sides to that format for reliable string comparison. + const toSqlite = (iso: string) => + iso.replace("T", " ").replace(/\.\d{3}Z$/, ""); + const rows = db.$client + .prepare( + `SELECT ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes + FROM host_metrics_history + WHERE host_id = ? AND ts >= ? AND ts <= ? + ORDER BY ts ASC`, + ) + .all(hostId, toSqlite(fromTs), toSqlite(toTs)); + + res.json({ rows, fromTs, toTs }); + } catch (error) { + statsLogger.error("Failed to fetch metrics history", { + operation: "metrics_history_fetch_error", + hostId, + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ error: "Failed to fetch metrics history" }); + } + }); +} diff --git a/src/backend/ssh/host-metrics-jump-hosts.ts b/src/backend/ssh/host-metrics-jump-hosts.ts index cfc88f6b..a54403b8 100644 --- a/src/backend/ssh/host-metrics-jump-hosts.ts +++ b/src/backend/ssh/host-metrics-jump-hosts.ts @@ -10,6 +10,8 @@ import { import { getDb } from "../database/db/index.js"; import { hosts, sshCredentials } from "../database/db/schema.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; +import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; interface JumpHostConfig { id: number; @@ -22,6 +24,12 @@ interface JumpHostConfig { keyType?: string; authType?: string; credentialId?: number; + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; [key: string]: unknown; } @@ -145,13 +153,17 @@ export async function createJumpHostChain( } } + const firstHopSocks5Config = getJumpHostSocks5Config( + jumpHostConfigs[0], + socks5Config, + ); let proxySocket: import("net").Socket | null = null; - if (socks5Config?.useSocks5) { + if (firstHopSocks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, - socks5Config, + firstHopSocks5Config, ); } @@ -170,7 +182,8 @@ export async function createJumpHostChain( true, ); - const connected = await new Promise((resolve) => { + // eslint-disable-next-line no-async-promise-executor + const connected = await new Promise(async (resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); @@ -261,6 +274,16 @@ export async function createJumpHostChain( if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } + } else if (jumpHostConfig.authType === "agent") { + const result = await applyAgentAuth( + connectConfig, + jumpHostConfig.terminalConfig as + | Record + | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } jumpClient.on( diff --git a/src/backend/ssh/host-metrics-preferences.test.ts b/src/backend/ssh/host-metrics-preferences.test.ts index e13a6e9e..10b3d1d8 100644 --- a/src/backend/ssh/host-metrics-preferences.test.ts +++ b/src/backend/ssh/host-metrics-preferences.test.ts @@ -37,7 +37,7 @@ describe("deriveEnabledWidgets", () => { ]); const out = deriveEnabledWidgets(slots); expect(out).toEqual(METRIC_CARD_IDS); - expect(out.length).toBe(10); + expect(out.length).toBe(METRIC_CARD_IDS.length); }); it("empty slots -> empty widgets", () => { diff --git a/src/backend/ssh/host-metrics-settings-routes.ts b/src/backend/ssh/host-metrics-settings-routes.ts index f3c2dc26..453485e9 100644 --- a/src/backend/ssh/host-metrics-settings-routes.ts +++ b/src/backend/ssh/host-metrics-settings-routes.ts @@ -186,4 +186,94 @@ export function registerHostMetricsSettingsRoutes( }); } }); + + /** + * @openapi + * /global-settings/history: + * get: + * summary: Get metrics history retention setting + * tags: + * - Host Metrics + * responses: + * 200: + * description: Retention setting in days. + * 403: + * description: Requires admin privileges. + */ + app.get("/global-settings/history", requireAdmin, (_req, res) => { + try { + const db = getDb(); + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", + ) + .get() as { value: string } | undefined; + const days = row ? parseInt(row.value, 10) : 7; + res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days }); + } catch (error) { + statsLogger.error("Failed to fetch history retention setting", { + operation: "history_retention_fetch_error", + error: error instanceof Error ? error.message : String(error), + }); + res + .status(500) + .json({ error: "Failed to fetch history retention setting" }); + } + }); + + /** + * @openapi + * /global-settings/history: + * post: + * summary: Update metrics history retention setting + * tags: + * - Host Metrics + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * metricsHistoryRetentionDays: + * type: integer + * minimum: 1 + * maximum: 90 + * responses: + * 200: + * description: Setting saved. + * 400: + * description: Invalid value. + * 403: + * description: Requires admin privileges. + */ + app.post("/global-settings/history", requireAdmin, (req, res) => { + const { metricsHistoryRetentionDays } = req.body; + if ( + typeof metricsHistoryRetentionDays !== "number" || + metricsHistoryRetentionDays < 1 || + metricsHistoryRetentionDays > 90 + ) { + return res.status(400).json({ + error: "metricsHistoryRetentionDays must be between 1 and 90", + }); + } + try { + const db = getDb(); + db.$client + .prepare( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('metrics_history_retention_days', ?)", + ) + .run(String(Math.floor(metricsHistoryRetentionDays))); + res.json({ success: true }); + } catch (error) { + statsLogger.error("Failed to save history retention setting", { + operation: "history_retention_save_error", + error: error instanceof Error ? error.message : String(error), + }); + res + .status(500) + .json({ error: "Failed to save history retention setting" }); + } + }); } diff --git a/src/backend/ssh/host-metrics.ts b/src/backend/ssh/host-metrics.ts index f05541e2..4ff506b3 100644 --- a/src/backend/ssh/host-metrics.ts +++ b/src/backend/ssh/host-metrics.ts @@ -24,15 +24,19 @@ import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; +import { collectTemperatureMetrics } from "./widgets/temperature-collector.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { connectionPool, withConnection } from "./ssh-connection-pool.js"; import { registerHostMetricsSettingsRoutes } from "./host-metrics-settings-routes.js"; import { registerHostMetricsViewerRoutes } from "./host-metrics-viewer-routes.js"; import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences-routes.js"; +import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js"; +import { AlertEngine } from "./alert-engine.js"; import { registerManagerRoutes } from "./managers/index.js"; import { AccessDeniedError } from "./managers/route-helpers.js"; import type { ManagerHost } from "./managers/types.js"; @@ -42,6 +46,7 @@ import { isTcpPingEnabled, supportsMetrics, } from "./host-metrics-helpers.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { cleanupMetricsSession, getSessionKey, @@ -129,6 +134,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = { "processes", "ports", "firewall", + "temperature", ], statusCheckEnabled: true, statusCheckInterval: 60, @@ -294,6 +300,8 @@ class PollingManager { viewerUserId, }; + this.pollingConfigs.set(host.id, config); + if (isTcpPingEnabled(statsConfig)) { const intervalMs = statsConfig.statusCheckInterval * 1000; @@ -335,8 +343,6 @@ class PollingManager { } else { this.metricsStore.delete(host.id); } - - this.pollingConfigs.set(host.id, config); } private async pollHostStatus( @@ -373,12 +379,18 @@ class PollingManager { lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); + AlertEngine.getInstance() + .evaluateStatus(refreshedHost.id, isOnline) + .catch(() => {}); } catch { const statusEntry: StatusEntry = { status: "offline", lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); + AlertEngine.getInstance() + .evaluateStatus(refreshedHost.id, false) + .catch(() => {}); } } @@ -420,6 +432,10 @@ class PollingManager { data: metrics, timestamp: Date.now(), }); + this.insertMetricsHistory(refreshedHost.id, metrics); + AlertEngine.getInstance() + .evaluateMetrics(refreshedHost.id, metrics) + .catch(() => {}); pollingBackoff.reset(refreshedHost.id); authFailureTracker.reset(refreshedHost.id); } catch (error) { @@ -458,6 +474,68 @@ class PollingManager { } } + private getRetentionDays(): number { + try { + const db = getDb(); + const row = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'metrics_history_retention_days'", + ) + .get() as { value: string } | undefined; + const days = row ? parseInt(row.value, 10) : 7; + return isNaN(days) || days < 1 ? 7 : Math.min(days, 90); + } catch { + return 7; + } + } + + private insertMetricsHistory( + hostId: number, + metrics: { + cpu: { percent: number | null }; + memory: { percent: number | null }; + disk: { percent: number | null }; + network: { + interfaces: Array<{ rxBytes: string | null; txBytes: string | null }>; + }; + }, + ): void { + try { + const db = getDb(); + const iface = metrics.network?.interfaces?.[0]; + const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null; + const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null; + + db.$client + .prepare( + `INSERT INTO host_metrics_history + (host_id, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + hostId, + metrics.cpu?.percent ?? null, + metrics.memory?.percent ?? null, + metrics.disk?.percent ?? null, + isNaN(rxRaw as number) ? null : rxRaw, + isNaN(txRaw as number) ? null : txRaw, + ); + + const retentionDays = this.getRetentionDays(); + db.$client + .prepare( + `DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)`, + ) + .run(hostId, `-${retentionDays} days`); + } catch (err) { + statsLogger.warn("Failed to write metrics history", { + operation: "insert_metrics_history", + hostId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + stopPollingForHost(hostId: number, clearData = true): void { const config = this.pollingConfigs.get(hostId); if (config) { @@ -767,6 +845,15 @@ async function resolveHostCredentials( : [], pin: !!host.pin, authType: host.authType, + terminalConfig: (() => { + try { + return typeof host.terminalConfig === "string" + ? JSON.parse(host.terminalConfig as string) + : host.terminalConfig || undefined; + } catch { + return undefined; + } + })(), enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: !!host.enableFileManager, @@ -1017,18 +1104,9 @@ async function buildSshConfig( } try { - if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) { - throw new Error("Invalid private key format"); - } - - const cleanKey = host.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - - (base as Record).privateKey = Buffer.from( - cleanKey, - "utf8", + (base as Record).privateKey = preparePrivateKeyForSSH2( + host.key, + host.keyPassword, ); if (host.keyPassword) { @@ -1068,6 +1146,14 @@ async function buildSshConfig( } else { throw new Error(`Credential for host ${host.ip} could not be resolved`); } + } else if (host.authType === "agent") { + const result = await applyAgentAuth( + base as Record, + (host as { terminalConfig?: Record }).terminalConfig, + ); + if ("error" in result) { + throw new Error(result.error); + } } else { throw new Error( `Unsupported authentication type '${host.authType}' for host ${host.ip}`, @@ -1154,6 +1240,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { client.end(); + jumpClient?.end(); reject(new Error("SSH connection timeout")); }, 30000); @@ -1162,8 +1249,13 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise { resolve(client); }); + client.on("close", () => { + jumpClient?.end(); + }); + client.on("error", (err) => { clearTimeout(timeout); + jumpClient?.end(); reject(err); }); @@ -1301,6 +1393,14 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ kernel: string | null; os: string | null; }; + temperature: { + source: "sysfs" | "sensors" | "none"; + highestCelsius: number | null; + sensors: Array<{ + label: string; + celsius: number; + }>; + }; }> { if (!supportsMetrics(host)) { throw new Error("Metrics collection only supported for SSH hosts"); @@ -1331,6 +1431,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ const uptime = await collectUptimeMetrics(client); const processes = await collectProcessesMetrics(client); const system = await collectSystemMetrics(client); + const temperature = await collectTemperatureMetrics(client); let login_stats = { recentLogins: [], @@ -1402,6 +1503,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ uptime, processes, system, + temperature, login_stats, ports, firewall, @@ -1880,6 +1982,20 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { return res.status(404).json({ error: "Host not found", connectionLogs }); } + if (!supportsMetrics(host)) { + connectionLogs.push( + createConnectionLog( + "info", + "stats_connecting", + "Metrics collection is only supported for SSH hosts", + { + connectionType: host.connectionType || "ssh", + }, + ), + ); + return res.json({ success: true, skipped: true, connectionLogs }); + } + connectionLogs.push( createConnectionLog( "info", @@ -1925,7 +2041,11 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { "Using existing metrics session", ), ); - return res.json({ success: true, connectionLogs }); + + const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + pollingManager.registerViewer(host.id, viewerSessionId, userId); + + return res.json({ success: true, viewerSessionId, connectionLogs }); } const config = await buildSshConfig(host); @@ -2542,6 +2662,12 @@ registerHostMetricsPreferencesRoutes(app, { (await permissionManager.canAccessHost(userId, hostId, level)).hasAccess, }); +registerHostMetricsHistoryRoutes(app, { + validateHostId, + canAccessHost: async (userId, hostId, level) => + (await permissionManager.canAccessHost(userId, hostId, level)).hasAccess, +}); + registerManagerRoutes(app, { validateHostId, runOnHost: async (hostId, userId, level, fn) => { @@ -2570,6 +2696,35 @@ registerManagerRoutes(app, { }, }); +// Internal endpoint — only accepts calls from localhost. +// Used by the main backend to notify the metrics service of SSH login events. +app.post("/internal/login-alert", async (req, res) => { + const remoteIp = req.socket.remoteAddress; + if ( + remoteIp !== "127.0.0.1" && + remoteIp !== "::1" && + remoteIp !== "::ffff:127.0.0.1" + ) { + return res.status(403).json({ error: "Forbidden" }); + } + const systemCrypto = (await import("../utils/system-crypto.js")).SystemCrypto; + const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); + const token = req.headers["x-internal-auth"]; + if (!token || token !== expectedToken) { + return res.status(403).json({ error: "Forbidden" }); + } + const { hostId, userId, sshUser, fromIp } = req.body as { + hostId: number; + userId: string; + sshUser: string; + fromIp: string; + }; + AlertEngine.getInstance() + .evaluateUserLogin(hostId, userId, sshUser, fromIp) + .catch(() => {}); + res.json({ ok: true }); +}); + process.on("SIGINT", () => { pollingManager.destroy(); connectionPool.destroy(); diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts index a856e536..4759fc9b 100644 --- a/src/backend/ssh/host-resolver.ts +++ b/src/backend/ssh/host-resolver.ts @@ -1,5 +1,5 @@ import { getDb } from "../database/db/index.js"; -import { hosts, sshCredentials } from "../database/db/schema.js"; +import { hosts, sshCredentials, vaultProfiles } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { logger } from "../utils/logger.js"; @@ -219,6 +219,28 @@ export async function resolveHostById( userId, ); + // Resolve a Vault SSH signer profile (shared settings, no secrets). The + // certificate itself is obtained per-user at connect time via Vault OIDC. + if (host.vaultProfileId) { + try { + const profiles = await db + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, host.vaultProfileId as number)) + .limit(1); + if (profiles.length > 0) { + (host as Record).vaultProfile = profiles[0]; + host.authType = "vault"; + } + } catch (e) { + sshLogger.warn("Failed to resolve vault profile for host", { + operation: "host_resolver_vault_profile", + hostId, + error: e instanceof Error ? e.message : "Unknown", + }); + } + } + return host as unknown as SSHHost; } diff --git a/src/backend/ssh/jump-host-chain.ts b/src/backend/ssh/jump-host-chain.ts index c2fd46fc..b8b11b5c 100644 --- a/src/backend/ssh/jump-host-chain.ts +++ b/src/backend/ssh/jump-host-chain.ts @@ -10,6 +10,8 @@ import { import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; +import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; type JumpHostConfig = { id: number; @@ -22,6 +24,12 @@ type JumpHostConfig = { keyType?: string; authType?: string; credentialId?: number; + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; [key: string]: unknown; }; @@ -145,13 +153,17 @@ export async function createJumpHostChain( } } + const firstHopSocks5Config = getJumpHostSocks5Config( + jumpHostConfigs[0], + socks5Config, + ); let proxySocket: import("net").Socket | null = null; - if (socks5Config?.useSocks5) { + if (firstHopSocks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, - socks5Config, + firstHopSocks5Config, ); } @@ -170,7 +182,8 @@ export async function createJumpHostChain( true, ); - const connected = await new Promise((resolve) => { + // eslint-disable-next-line no-async-promise-executor + const connected = await new Promise(async (resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); @@ -261,6 +274,16 @@ export async function createJumpHostChain( if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } + } else if (jumpHostConfig.authType === "agent") { + const result = await applyAgentAuth( + connectConfig, + jumpHostConfig.terminalConfig as + | Record + | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } jumpClient.on( diff --git a/src/backend/ssh/jump-host-proxy.ts b/src/backend/ssh/jump-host-proxy.ts new file mode 100644 index 00000000..e16cb746 --- /dev/null +++ b/src/backend/ssh/jump-host-proxy.ts @@ -0,0 +1,51 @@ +import type { ProxyNode } from "../../types/index.js"; +import type { SOCKS5Config } from "../utils/socks5-helper.js"; + +type JumpHostProxyConfig = { + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: ProxyNode[] | string | null; +}; + +function parseProxyChain(value: JumpHostProxyConfig["socks5ProxyChain"]) { + if (Array.isArray(value)) { + return value; + } + + if (typeof value !== "string" || value.length === 0) { + return []; + } + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? (parsed as ProxyNode[]) : []; + } catch { + return []; + } +} + +export function getJumpHostSocks5Config( + firstHop: JumpHostProxyConfig | null | undefined, + fallbackConfig?: SOCKS5Config | null, +): SOCKS5Config | null { + if (!firstHop?.useSocks5) { + return fallbackConfig ?? null; + } + + const socks5ProxyChain = parseProxyChain(firstHop.socks5ProxyChain); + if (!firstHop.socks5Host && socks5ProxyChain.length === 0) { + return fallbackConfig ?? null; + } + + return { + useSocks5: true, + socks5Host: firstHop.socks5Host ?? undefined, + socks5Port: firstHop.socks5Port ?? undefined, + socks5Username: firstHop.socks5Username ?? undefined, + socks5Password: firstHop.socks5Password ?? undefined, + socks5ProxyChain, + }; +} diff --git a/src/backend/ssh/managers/health.ts b/src/backend/ssh/managers/health.ts index e9bb36e6..f8ded3f5 100644 --- a/src/backend/ssh/managers/health.ts +++ b/src/backend/ssh/managers/health.ts @@ -7,6 +7,7 @@ import { managerHandler, ManagerInputError } from "./route-helpers.js"; import { shellSingleQuote } from "./exec-elevated.js"; import { isValidPort } from "./validation.js"; import type { ManagerRoutesDeps } from "./types.js"; +import { AlertEngine } from "../alert-engine.js"; export interface HealthCheck { id: string; @@ -178,7 +179,20 @@ export function registerHealthRoutes( const userId = (req as AuthenticatedRequest).userId; const checks = loadChecks(userId, host.id); const results = checks.length ? await runChecks(client, checks) : []; - if (results.length) recordHistory(userId, host.id, results); + if (results.length) { + recordHistory(userId, host.id, results); + for (const r of results) { + AlertEngine.getInstance() + .evaluateHealthCheck( + host.id, + userId, + r.checkId, + r.ok, + r.detail ?? undefined, + ) + .catch(() => {}); + } + } const history = getDb() .$client.prepare( @@ -249,7 +263,20 @@ export function registerHealthRoutes( const userId = (req as AuthenticatedRequest).userId; const checks = loadChecks(userId, host.id); const results = await runChecks(client, checks); - if (results.length) recordHistory(userId, host.id, results); + if (results.length) { + recordHistory(userId, host.id, results); + for (const r of results) { + AlertEngine.getInstance() + .evaluateHealthCheck( + host.id, + userId, + r.checkId, + r.ok, + r.detail ?? undefined, + ) + .catch(() => {}); + } + } return { results }; }, ), diff --git a/src/backend/ssh/managers/index.ts b/src/backend/ssh/managers/index.ts index e2da7550..2a281dd5 100644 --- a/src/backend/ssh/managers/index.ts +++ b/src/backend/ssh/managers/index.ts @@ -12,6 +12,8 @@ import { registerFirewallRoutes } from "./firewall.js"; import { registerUserRoutes } from "./users.js"; import { registerHealthRoutes } from "./health.js"; import { registerLogRoutes } from "./logs.js"; +import { registerWireGuardRoutes } from "./wireguard.js"; +import { registerTailscaleRoutes } from "./tailscale.js"; /** * Registers every Host Metrics manager route under the `/host-metrics/managers` @@ -58,4 +60,6 @@ export function registerManagerRoutes( registerUserRoutes(app, deps); registerHealthRoutes(app, deps); registerLogRoutes(app, deps); + registerWireGuardRoutes(app, deps); + registerTailscaleRoutes(app, deps); } diff --git a/src/backend/ssh/managers/tailscale.ts b/src/backend/ssh/managers/tailscale.ts new file mode 100644 index 00000000..4c63bf1c --- /dev/null +++ b/src/backend/ssh/managers/tailscale.ts @@ -0,0 +1,177 @@ +import type { Express } from "express"; +import { execCommand } from "../widgets/common-utils.js"; +import { execElevated } from "./exec-elevated.js"; +import { managerHandler, ManagerInputError } from "./route-helpers.js"; +import type { ManagerRoutesDeps } from "./types.js"; +import { isValidTailscaleAction } from "./validation.js"; + +export interface TailscalePeer { + hostname: string; + tailscaleIPs: string[]; + online: boolean; + isExitNode: boolean; +} + +export interface TailscaleData { + installed: boolean; + running: boolean; + tailscaleIPs: string[]; + hostname: string | null; + peers: TailscalePeer[]; + exitNodeInUse: boolean; +} + +const PROBE_CMD = [ + "command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0", + "tailscale status --json 2>/dev/null", +].join("; "); + +export function parseTailscaleData(output: string): TailscaleData { + const notInstalled: TailscaleData = { + installed: false, + running: false, + tailscaleIPs: [], + hostname: null, + peers: [], + exitNodeInUse: false, + }; + + if (output.includes("ts_installed=0")) return notInstalled; + + // Strip the installation probe line to get the raw JSON + const lines = output.split("\n"); + const jsonLines = lines.filter( + (l) => !l.startsWith("ts_installed=") && l.trim() !== "", + ); + const jsonStr = jsonLines.join("\n"); + + try { + const parsed = JSON.parse(jsonStr) as { + BackendState?: string; + Self?: { HostName?: string; TailscaleIPs?: string[] }; + Peer?: Record< + string, + { + HostName?: string; + TailscaleIPs?: string[]; + Online?: boolean; + ExitNode?: boolean; + } + >; + CurrentExitNode?: string; + }; + + const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map( + (p) => ({ + hostname: p.HostName ?? "", + tailscaleIPs: p.TailscaleIPs ?? [], + online: p.Online ?? false, + isExitNode: p.ExitNode ?? false, + }), + ); + + return { + installed: true, + running: parsed.BackendState === "Running", + tailscaleIPs: parsed.Self?.TailscaleIPs ?? [], + hostname: parsed.Self?.HostName ?? null, + peers, + exitNodeInUse: + typeof parsed.CurrentExitNode === "string" && + parsed.CurrentExitNode !== "", + }; + } catch { + return { + installed: true, + running: false, + tailscaleIPs: [], + hostname: null, + peers: [], + exitNodeInUse: false, + }; + } +} + +export function registerTailscaleRoutes( + app: Express, + { validateHostId, runOnHost }: ManagerRoutesDeps, +): void { + /** + * @openapi + * /host-metrics/managers/tailscale/{id}: + * get: + * summary: Get Tailscale status and IPs + * tags: + * - Host Metrics + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Tailscale installation status, running state, IPs, and peer count. + */ + app.get( + "/host-metrics/managers/tailscale/:id", + validateHostId, + managerHandler(runOnHost, "read", "tailscale_read", async (client) => { + const { stdout } = await execCommand(client, PROBE_CMD, 15000); + return parseTailscaleData(stdout); + }), + ); + + /** + * @openapi + * /host-metrics/managers/tailscale/{id}/action: + * post: + * summary: Connect or disconnect Tailscale + * tags: + * - Host Metrics + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * enum: [up, down] + * responses: + * 200: + * description: Action result. + */ + app.post( + "/host-metrics/managers/tailscale/:id/action", + validateHostId, + managerHandler( + runOnHost, + "execute", + "tailscale_action", + async (client, host, req) => { + const { action } = req.body as { action: unknown }; + if (!isValidTailscaleAction(action)) { + throw new ManagerInputError("Invalid action, must be 'up' or 'down'"); + } + const result = await execElevated( + client, + `tailscale ${action}`, + host.sudoPassword, + { forceSudo: false, timeoutMs: 30000 }, + ); + return { + success: result.code === 0, + output: (result.stdout + result.stderr).trim(), + }; + }, + ), + ); +} diff --git a/src/backend/ssh/managers/validation.ts b/src/backend/ssh/managers/validation.ts index 2104d6bd..73e10ada 100644 --- a/src/backend/ssh/managers/validation.ts +++ b/src/backend/ssh/managers/validation.ts @@ -93,6 +93,23 @@ export function isValidFirewallTarget(t: unknown): t is FirewallTarget { return typeof t === "string" && (FW_TARGETS as string[]).includes(t); } +const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/; +export function isValidWireGuardInterface(name: unknown): name is string { + return typeof name === "string" && WG_IFACE_RE.test(name); +} + +export type WireGuardAction = "up" | "down"; +const WG_ACTIONS: WireGuardAction[] = ["up", "down"]; +export function isValidWireGuardAction(a: unknown): a is WireGuardAction { + return typeof a === "string" && (WG_ACTIONS as string[]).includes(a); +} + +export type TailscaleAction = "up" | "down"; +const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"]; +export function isValidTailscaleAction(a: unknown): a is TailscaleAction { + return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a); +} + /** * Validate an absolute file path against an allowlist of permitted prefixes and * reject traversal. Used by the log viewer (e.g. only under /var/log). diff --git a/src/backend/ssh/managers/wireguard.ts b/src/backend/ssh/managers/wireguard.ts new file mode 100644 index 00000000..045b8ef0 --- /dev/null +++ b/src/backend/ssh/managers/wireguard.ts @@ -0,0 +1,223 @@ +import type { Express } from "express"; +import { execElevated } from "./exec-elevated.js"; +import { managerHandler } from "./route-helpers.js"; +import { ManagerInputError } from "./route-helpers.js"; +import type { ManagerRoutesDeps } from "./types.js"; +import { + isValidWireGuardInterface, + isValidWireGuardAction, +} from "./validation.js"; + +export interface WireGuardPeer { + publicKey: string; + endpoint: string | null; + allowedIPs: string[]; + latestHandshake: number | null; + rxBytes: number; + txBytes: number; +} + +export interface WireGuardInterface { + name: string; + publicKey: string | null; + listenPort: number | null; + up: boolean; + peers: WireGuardPeer[]; +} + +export interface WireGuardData { + installed: boolean; + interfaces: WireGuardInterface[]; +} + +const PROBE_CMD = [ + "command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0", + "ip link show type wireguard 2>/dev/null", + "echo __DUMP__", + "wg show all dump 2>/dev/null", +].join("; "); + +export function parseWireGuardData(output: string): WireGuardData { + if (output.includes("wg_installed=0")) { + return { installed: false, interfaces: [] }; + } + + const dumpIdx = output.indexOf("__DUMP__"); + const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : ""; + const dumpPart = + dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : ""; + + // Collect up interface names from `ip link show type wireguard` + const upSet = new Set(); + for (const line of ipLinkPart.split("\n")) { + const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/); + if (m) upSet.add(m[1]); + } + + const ifaceMap = new Map(); + + for (const raw of dumpPart.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const cols = line.split("\t"); + + if (cols.length === 5) { + // Interface row: iface private_key public_key listen_port fwmark + const [name, , publicKey, listenPortStr] = cols; + if (!name) continue; + ifaceMap.set(name, { + name, + publicKey: publicKey && publicKey !== "(none)" ? publicKey : null, + listenPort: + listenPortStr && listenPortStr !== "(none)" + ? Number(listenPortStr) + : null, + up: upSet.has(name), + peers: [], + }); + } else if (cols.length === 9) { + // Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive + const [ + name, + publicKey, + , + endpoint, + allowedIPsStr, + handshakeStr, + rxStr, + txStr, + ] = cols; + if (!name) continue; + + if (!ifaceMap.has(name)) { + ifaceMap.set(name, { + name, + publicKey: null, + listenPort: null, + up: upSet.has(name), + peers: [], + }); + } + + const handshake = Number(handshakeStr); + ifaceMap.get(name)!.peers.push({ + publicKey: publicKey ?? "", + endpoint: endpoint && endpoint !== "(none)" ? endpoint : null, + allowedIPs: + allowedIPsStr && allowedIPsStr !== "(none)" + ? allowedIPsStr.split(",").map((s) => s.trim()) + : [], + latestHandshake: handshake > 0 ? handshake : null, + rxBytes: Number(rxStr) || 0, + txBytes: Number(txStr) || 0, + }); + } + } + + return { installed: true, interfaces: Array.from(ifaceMap.values()) }; +} + +export function registerWireGuardRoutes( + app: Express, + { validateHostId, runOnHost }: ManagerRoutesDeps, +): void { + /** + * @openapi + * /host-metrics/managers/wireguard/{id}: + * get: + * summary: Get WireGuard interfaces and peers + * tags: + * - Host Metrics + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: WireGuard installation status, interfaces, and peer details. + */ + app.get( + "/host-metrics/managers/wireguard/:id", + validateHostId, + managerHandler( + runOnHost, + "read", + "wireguard_read", + async (client, host) => { + const result = await execElevated( + client, + PROBE_CMD, + host.sudoPassword, + { + forceSudo: false, + timeoutMs: 15000, + }, + ); + return parseWireGuardData(result.stdout); + }, + ), + ); + + /** + * @openapi + * /host-metrics/managers/wireguard/{id}/action: + * post: + * summary: Bring a WireGuard interface up or down + * tags: + * - Host Metrics + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * interface: + * type: string + * action: + * type: string + * enum: [up, down] + * responses: + * 200: + * description: Action result. + */ + app.post( + "/host-metrics/managers/wireguard/:id/action", + validateHostId, + managerHandler( + runOnHost, + "execute", + "wireguard_action", + async (client, host, req) => { + const { interface: iface, action } = req.body as { + interface: unknown; + action: unknown; + }; + if (!isValidWireGuardInterface(iface)) { + throw new ManagerInputError("Invalid WireGuard interface name"); + } + if (!isValidWireGuardAction(action)) { + throw new ManagerInputError("Invalid action, must be 'up' or 'down'"); + } + const result = await execElevated( + client, + `wg-quick ${action} ${iface}`, + host.sudoPassword, + { forceSudo: true, timeoutMs: 30000 }, + ); + return { + success: result.code === 0, + output: (result.stdout + result.stderr).trim(), + }; + }, + ), + ); +} diff --git a/src/backend/ssh/terminal-agent-auth.test.ts b/src/backend/ssh/terminal-agent-auth.test.ts new file mode 100644 index 00000000..2ac0414d --- /dev/null +++ b/src/backend/ssh/terminal-agent-auth.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockAccess = vi.fn(); + +vi.mock("fs/promises", () => ({ + access: mockAccess, +})); + +import { applyAgentAuth, resolveAgentSocket } from "./terminal-auth-helpers.js"; + +describe("resolveAgentSocket", () => { + const originalEnv = process.env.SSH_AUTH_SOCK; + const originalPlatform = process.platform; + + beforeEach(() => { + mockAccess.mockReset(); + delete process.env.SSH_AUTH_SOCK; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.SSH_AUTH_SOCK = originalEnv; + } else { + delete process.env.SSH_AUTH_SOCK; + } + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it("uses explicit socket path from terminalConfig over SSH_AUTH_SOCK", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.SSH_AUTH_SOCK = "/tmp/ssh-env/agent.123"; + mockAccess.mockResolvedValue(undefined); + + const result = await resolveAgentSocket({ + agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh", + }); + + expect(result).toEqual({ + socketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh", + }); + expect(mockAccess).toHaveBeenCalledWith( + "/run/user/1000/gnupg/S.gpg-agent.ssh", + ); + }); + + it("falls back to SSH_AUTH_SOCK when no explicit path is provided", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.456"; + mockAccess.mockResolvedValue(undefined); + + const result = await resolveAgentSocket({}); + + expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.456" }); + }); + + it("falls back to SSH_AUTH_SOCK when agentSocketPath is empty string", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.SSH_AUTH_SOCK = "/tmp/ssh-XXXX/agent.789"; + mockAccess.mockResolvedValue(undefined); + + const result = await resolveAgentSocket({ agentSocketPath: " " }); + + expect(result).toEqual({ socketPath: "/tmp/ssh-XXXX/agent.789" }); + }); + + it("reads agent socket path from serialized terminal config", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + mockAccess.mockResolvedValue(undefined); + + const result = await resolveAgentSocket( + JSON.stringify({ agentSocketPath: "/tmp/serialized-agent.sock" }), + ); + + expect(result).toEqual({ socketPath: "/tmp/serialized-agent.sock" }); + }); + + it("returns error for invalid serialized terminal config", async () => { + const result = await resolveAgentSocket("{"); + + expect(result).toEqual({ + error: "Invalid terminal configuration for SSH agent auth.", + }); + }); + + it("returns error when neither SSH_AUTH_SOCK nor explicit path is set", async () => { + const result = await resolveAgentSocket({}); + + expect(result).toHaveProperty("error"); + expect((result as { error: string }).error).toContain("SSH_AUTH_SOCK"); + }); + + it("returns error when terminalConfig is undefined and SSH_AUTH_SOCK is not set", async () => { + const result = await resolveAgentSocket(undefined); + + expect(result).toHaveProperty("error"); + }); + + it("returns error on non-Windows when socket file is missing", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.SSH_AUTH_SOCK = "/tmp/missing-agent.sock"; + mockAccess.mockRejectedValue(new Error("ENOENT")); + + const result = await resolveAgentSocket({}); + + expect(result).toHaveProperty("error"); + expect((result as { error: string }).error).toContain( + "/tmp/missing-agent.sock", + ); + }); + + it("skips file existence check on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.SSH_AUTH_SOCK = "\\\\.\\pipe\\openssh-ssh-agent"; + + const result = await resolveAgentSocket({}); + + expect(result).toEqual({ + socketPath: "\\\\.\\pipe\\openssh-ssh-agent", + }); + expect(mockAccess).not.toHaveBeenCalled(); + }); + + it("attaches an ssh2 agent to a connect config", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + mockAccess.mockResolvedValue(undefined); + const config: Record = {}; + + const result = await applyAgentAuth(config, { + agentSocketPath: "/tmp/ssh-agent.sock", + }); + + expect(result).toEqual({ socketPath: "/tmp/ssh-agent.sock" }); + expect(config.agent).toBeDefined(); + }); +}); diff --git a/src/backend/ssh/terminal-auth-helpers.ts b/src/backend/ssh/terminal-auth-helpers.ts index c3329b4c..786a6a3a 100644 --- a/src/backend/ssh/terminal-auth-helpers.ts +++ b/src/backend/ssh/terminal-auth-helpers.ts @@ -44,6 +44,56 @@ export class MemoryAgent extends BaseAgent { } } +export async function resolveAgentSocket( + terminalConfig: Record | string | undefined, +): Promise<{ socketPath: string } | { error: string }> { + let parsedConfig: Record | undefined; + if (typeof terminalConfig === "string") { + try { + parsedConfig = JSON.parse(terminalConfig) as Record; + } catch { + return { error: "Invalid terminal configuration for SSH agent auth." }; + } + } else { + parsedConfig = terminalConfig; + } + const explicit = ( + parsedConfig?.agentSocketPath as string | undefined + )?.trim(); + const resolved = explicit || process.env.SSH_AUTH_SOCK; + + if (!resolved) { + return { + error: "SSH_AUTH_SOCK is not set and no socket path was provided.", + }; + } + + if (process.platform !== "win32") { + const { access } = await import("fs/promises"); + try { + await access(resolved); + } catch { + return { + error: `SSH agent socket not found at ${resolved}. Make sure your agent is running.`, + }; + } + } + + return { socketPath: resolved }; +} + +export async function applyAgentAuth( + connectConfig: Record, + terminalConfig: Record | string | undefined, +): Promise<{ socketPath: string } | { error: string }> { + const result = await resolveAgentSocket(terminalConfig); + if ("error" in result) return result; + + const { createAgent } = ssh2Pkg; + connectConfig.agent = createAgent(result.socketPath); + return result; +} + export async function performPortKnocking( host: string, sequence: Array<{ port: number; protocol?: string; delay?: number }>, diff --git a/src/backend/ssh/terminal-jump-hosts.ts b/src/backend/ssh/terminal-jump-hosts.ts index 54530a04..2f99e3a6 100644 --- a/src/backend/ssh/terminal-jump-hosts.ts +++ b/src/backend/ssh/terminal-jump-hosts.ts @@ -10,6 +10,8 @@ import { type SOCKS5Config, } from "../utils/socks5-helper.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; +import { getJumpHostSocks5Config } from "./jump-host-proxy.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; const { Client } = ssh2Pkg; @@ -24,6 +26,12 @@ interface JumpHostConfig { keyType?: string; authType?: string; credentialId?: number; + useSocks5?: boolean | null; + socks5Host?: string | null; + socks5Port?: number | null; + socks5Username?: string | null; + socks5Password?: string | null; + socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null; [key: string]: unknown; } @@ -149,13 +157,17 @@ export async function createJumpHostChain( } } + const firstHopSocks5Config = getJumpHostSocks5Config( + jumpHostConfigs[0], + socks5Config, + ); let proxySocket: import("net").Socket | null = null; - if (socks5Config?.useSocks5) { + if (firstHopSocks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, - socks5Config, + firstHopSocks5Config, ); } @@ -174,7 +186,8 @@ export async function createJumpHostChain( true, ); - const connected = await new Promise((resolve) => { + // eslint-disable-next-line no-async-promise-executor + const connected = await new Promise(async (resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); @@ -275,6 +288,16 @@ export async function createJumpHostChain( if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } + } else if (jumpHostConfig.authType === "agent") { + const result = await applyAgentAuth( + connectConfig, + jumpHostConfig.terminalConfig as + | Record + | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } jumpClient.on( diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 1d556c11..31cbb7d6 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -29,8 +29,14 @@ import { attachOrCreateTmuxSession, waitForTmuxSession, } from "./tmux-helper.js"; -import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js"; +import { + applyAgentAuth, + MemoryAgent, + performPortKnocking, +} from "./terminal-auth-helpers.js"; import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; +import { triggerLoginAlert } from "../utils/alert-trigger.js"; interface ConnectToHostData { cols: number; @@ -49,6 +55,8 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + passwordFallbackOnly?: boolean; + passwordFallbackAttempted?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; @@ -774,7 +782,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const opksshData = data as { hostId: number }; try { const { startOPKSSHAuth } = await import("./opkssh-auth.js"); - const { getRequestOrigin } = + const { getRequestBaseUrl } = await import("../utils/request-origin.js"); const db = getDb(); const hostRow = await db @@ -801,7 +809,7 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } const hostname = hostRow[0].name || hostRow[0].ip; - const requestOrigin = getRequestOrigin(req); + const requestOrigin = getRequestBaseUrl(req); await startOPKSSHAuth( userId, opksshData.hostId, @@ -887,6 +895,110 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } + case "vault_start_auth": { + const vaultData = data as { hostId: number }; + try { + const { loadVaultProfileForHost, startVaultAuth } = + await import("./vault-oidc-auth.js"); + const { getRequestBaseUrl } = + await import("../utils/request-origin.js"); + const profile = await loadVaultProfileForHost(vaultData.hostId); + if (!profile) { + ws.send( + JSON.stringify({ + type: "vault_error", + hostId: vaultData.hostId, + error: "No Vault signer profile configured for this host", + }), + ); + break; + } + const requestOrigin = getRequestBaseUrl(req); + await startVaultAuth( + userId, + vaultData.hostId, + profile, + ws, + requestOrigin, + ); + } catch (error) { + sshLogger.error("Failed to start Vault auth", error, { + operation: "vault_start_auth_error", + userId, + hostId: vaultData.hostId, + }); + ws.send( + JSON.stringify({ + type: "vault_error", + hostId: vaultData.hostId, + error: + error instanceof Error + ? error.message + : "Failed to start Vault authentication", + }), + ); + } + break; + } + + case "vault_cancel": { + const cancelData = data as { hostId: number }; + try { + const { cancelVaultAuthByHost } = + await import("./vault-oidc-auth.js"); + cancelVaultAuthByHost(userId, cancelData.hostId); + resetConnectionState(); + } catch (error) { + sshLogger.error("Failed to cancel Vault auth", error, { + operation: "vault_cancel_error", + userId, + }); + } + break; + } + + case "vault_auth_completed": { + const completedData = data as { + hostId: number; + cols?: number; + rows?: number; + hostConfig?: ConnectToHostData["hostConfig"]; + }; + + resetConnectionState(); + + const reconnectConfig: ConnectToHostData = { + cols: completedData.cols || 80, + rows: completedData.rows || 24, + hostConfig: + completedData.hostConfig || + ({ + id: completedData.hostId, + ip: "", + port: 22, + username: "", + userId, + } as ConnectToHostData["hostConfig"]), + }; + + handleConnectToHost(reconnectConfig).catch((error) => { + sshLogger.error("Failed to reconnect after Vault auth", error, { + operation: "vault_reconnect_error", + userId, + hostId: completedData.hostId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to connect after authentication: " + + (error instanceof Error ? error.message : "Unknown error"), + }), + ); + }); + break; + } + default: sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", @@ -993,9 +1105,6 @@ wss.on("connection", async (ws: WebSocket, req) => { isConnecting = true; sshConn = new Client(); - sendLog("dns", "info", `Starting address resolution of ${ip}`); - sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); - const connectionTimeout = setTimeout(() => { if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { @@ -1047,6 +1156,10 @@ wss.on("connection", async (ws: WebSocket, req) => { )) as unknown as typeof resolvedHostData; if (resolvedHostData) { + ip = resolvedHostData.ip || ip; + port = resolvedHostData.port || port; + username = resolvedHostData.username || username; + if ( (!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) && resolvedHostData.jumpHosts && @@ -1096,11 +1209,8 @@ wss.on("connection", async (ws: WebSocket, req) => { if (id && userId && !password && !key) { try { if (resolvedHostData) { - ip = resolvedHostData.ip || ip; - port = resolvedHostData.port || port; - username = resolvedHostData.username || username; resolvedCredentials = { - username: resolvedHostData.username || username, + username, password: resolvedHostData.password, key: resolvedHostData.key, keyPassword: keyPassword || resolvedHostData.keyPassword, @@ -1124,11 +1234,8 @@ wss.on("connection", async (ws: WebSocket, req) => { } else if (credentialId && id && userId) { try { if (resolvedHostData) { - ip = resolvedHostData.ip || ip; - port = resolvedHostData.port || port; - username = resolvedHostData.username || username; resolvedCredentials = { - username: resolvedHostData.username || username, + username, password: resolvedHostData.password, key: resolvedHostData.key, // Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value @@ -1148,6 +1255,20 @@ wss.on("connection", async (ws: WebSocket, req) => { } } + if (hostConfig.passwordFallbackOnly && resolvedCredentials.password) { + resolvedCredentials = { + ...resolvedCredentials, + key: undefined, + keyPassword: undefined, + keyType: undefined, + certPublicKey: undefined, + authType: "password", + }; + } + + sendLog("dns", "info", `Starting address resolution of ${ip}`); + sendLog("tcp", "info", `Connecting to ${ip} port ${port}`); + sshConn.on("ready", () => { clearTimeout(connectionTimeout); sshLogger.success("SSH connection established", { @@ -1634,6 +1755,15 @@ wss.on("connection", async (ws: WebSocket, req) => { JSON.stringify({ type: "connected", message: "SSH connected" }), ); + if (id && hostConfig.userId) { + triggerLoginAlert( + id, + hostConfig.userId, + username, + req.socket.remoteAddress ?? "unknown", + ).catch(() => {}); + } + if (id && hostConfig.userId) { (async () => { try { @@ -1702,6 +1832,72 @@ wss.on("connection", async (ws: WebSocket, req) => { keyboardInteractiveResponded, }); + const isAuthFailure = + err.message.includes("All configured authentication methods failed") || + err.message.includes("authentication failed") || + err.message.includes("Authentication failed") || + err.message.includes("Permission denied"); + + if ( + resolvedCredentials.authType === "key" && + resolvedCredentials.password && + isAuthFailure && + !hostConfig.passwordFallbackAttempted + ) { + sendLog( + "auth", + "warning", + "SSH key authentication failed; retrying with stored password", + ); + sshLogger.warn("Retrying SSH connection with stored password", { + operation: "terminal_key_password_fallback", + hostId: id, + userId, + }); + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); + sshConn = null; + sshStream = null; + handleConnectToHost({ + ...data, + hostConfig: { + ...hostConfig, + authType: "password", + password: resolvedCredentials.password, + key: undefined, + keyPassword: undefined, + keyType: undefined, + passwordFallbackOnly: true, + passwordFallbackAttempted: true, + }, + }).catch((fallbackError) => { + const fallbackMessage = + fallbackError instanceof Error + ? fallbackError.message + : "Unknown error"; + sshLogger.error( + "Password fallback connection failed", + fallbackError, + { + operation: "terminal_key_password_fallback_error", + hostId: id, + userId, + }, + ); + ws.send( + JSON.stringify({ + type: "error", + message: + "Failed to retry with stored password: " + fallbackMessage, + }), + ); + }); + return; + } + if ( resolvedCredentials.authType === "opkssh" && err.message.includes("All configured authentication methods failed") @@ -1750,6 +1946,60 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + if ( + resolvedCredentials.authType === "vault" && + err.message.includes("All configured authentication methods failed") + ) { + sshLogger.warn("Vault certificate authentication failed", { + operation: "vault_auth_failed", + hostId: id, + userId, + error: err.message, + }); + + (async () => { + try { + const profileId = ( + resolvedHostData?.vaultProfile as { id?: number } | undefined + )?.id; + if (profileId) { + const { deleteVaultCert } = + await import("./vault-signer-auth.js"); + await deleteVaultCert(userId, profileId); + } + } catch (invalidateError) { + sshLogger.error("Failed to invalidate Vault certificate", { + operation: "vault_cert_invalidation_error", + userId, + hostId: id, + error: invalidateError, + }); + } + })(); + + if (currentSessionId) { + sessionManager.destroySession(currentSessionId); + currentSessionId = null; + } + cleanupAuthState(connectionTimeout); + + sendLog( + "auth", + "error", + "Vault certificate authentication failed. Please authenticate again.", + ); + + ws.send( + JSON.stringify({ + type: "vault_auth_required", + hostId: id, + message: + "Vault authentication failed or expired. Please authenticate again.", + }), + ); + return; + } + if ( err.message.includes("Cannot parse privateKey") && err.message.includes("no passphrase") @@ -2127,19 +2377,10 @@ wss.on("connection", async (ws: WebSocket, req) => { ) { sendLog("auth", "info", "Using SSH key authentication"); try { - if ( - !resolvedCredentials.key.includes("-----BEGIN") || - !resolvedCredentials.key.includes("-----END") - ) { - throw new Error("Invalid private key format"); - } - - const cleanKey = resolvedCredentials.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - - connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + connectConfig.privateKey = preparePrivateKeyForSSH2( + resolvedCredentials.key, + resolvedCredentials.keyPassword, + ); if (resolvedCredentials.keyPassword) { connectConfig.passphrase = resolvedCredentials.keyPassword; @@ -2188,11 +2429,15 @@ wss.on("connection", async (ws: WebSocket, req) => { } } } catch (keyError) { - sshLogger.error("SSH key format error: " + keyError.message); + const message = + keyError instanceof Error + ? keyError.message + : "Invalid private key format"; + sshLogger.error("SSH key format error: " + message); ws.send( JSON.stringify({ type: "error", - message: "SSH key format error: Invalid private key format", + message: `SSH key format error: ${message}`, }), ); return; @@ -2254,6 +2499,76 @@ wss.on("connection", async (ws: WebSocket, req) => { ); return; } + } else if (resolvedCredentials.authType === "vault") { + sendLog("auth", "info", "Using Vault SSH signer authentication"); + try { + const vaultProfile = resolvedHostData?.vaultProfile as + | { id: number } + | undefined; + if (!vaultProfile?.id) { + throw new Error("Host has no Vault signer profile configured"); + } + + const { getVaultCert } = await import("./vault-signer-auth.js"); + const cert = await getVaultCert(userId, vaultProfile.id); + + if (!cert) { + sendLog( + "auth", + "info", + "No valid Vault certificate found, requesting authentication", + ); + ws.send( + JSON.stringify({ + type: "vault_auth_required", + hostId: id, + }), + ); + return; + } + + sendLog("auth", "info", "Using cached Vault-signed certificate"); + + const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js"); + await setupOPKSSHCertAuth( + connectConfig, + sshConn, + { privateKey: cert.privateKey, sshCert: cert.sshCert }, + username, + ); + } catch (vaultError) { + sshLogger.error("Vault SSH signer authentication error", vaultError, { + operation: "vault_auth_error", + userId, + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "error", + message: + "Vault SSH signer authentication failed: " + + (vaultError instanceof Error + ? vaultError.message + : "Unknown error"), + }), + ); + return; + } + } else if (resolvedCredentials.authType === "agent") { + sendLog("auth", "info", "Using SSH agent authentication"); + const result = await applyAgentAuth( + connectConfig as Record, + hostConfig.terminalConfig as Record | undefined, + ); + if ("error" in result) { + ws.send(JSON.stringify({ type: "error", message: result.error })); + return; + } + sendLog( + "auth", + "info", + `SSH agent configured (socket: ${result.socketPath})`, + ); } else { sendLog("auth", "info", "Using keyboard-interactive authentication"); sshLogger.error("No valid authentication method provided"); @@ -2266,25 +2581,34 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - if ( - hostConfig.terminalConfig?.agentForwarding && - connectConfig.privateKey - ) { - try { - const parsed = ssh2Utils.parseKey( - connectConfig.privateKey as Buffer, - connectConfig.passphrase as string | undefined, - ); - if (parsed && !(parsed instanceof Error)) { - connectConfig.agent = new MemoryAgent(parsed); - connectConfig.agentForward = true; - sendLog("auth", "info", "SSH agent forwarding enabled"); + if (hostConfig.terminalConfig?.agentForwarding) { + if (connectConfig.privateKey) { + try { + const parsed = ssh2Utils.parseKey( + connectConfig.privateKey as Buffer, + connectConfig.passphrase as string | undefined, + ); + if (parsed && !(parsed instanceof Error)) { + connectConfig.agent = new MemoryAgent(parsed); + connectConfig.agentForward = true; + sendLog("auth", "info", "SSH agent forwarding enabled"); + } + } catch { + sshLogger.warn("Failed to set up agent forwarding", { + operation: "agent_forward_setup", + hostId: id, + }); } - } catch { - sshLogger.warn("Failed to set up agent forwarding", { - operation: "agent_forward_setup", - hostId: id, - }); + } else if ( + resolvedCredentials.authType === "agent" && + connectConfig.agent + ) { + connectConfig.agentForward = true; + sendLog( + "auth", + "info", + "SSH agent forwarding enabled (external agent)", + ); } } diff --git a/src/backend/ssh/tmux-helper.test.ts b/src/backend/ssh/tmux-helper.test.ts new file mode 100644 index 00000000..04e97dce --- /dev/null +++ b/src/backend/ssh/tmux-helper.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { tmuxCommand, withTmuxPath } from "./tmux-helper.js"; + +describe("tmux command path handling", () => { + it("adds common non-login shell tmux paths", () => { + const command = withTmuxPath("command -v tmux"); + + expect(command).toContain("/opt/homebrew/bin"); + expect(command).toContain("/usr/local/bin"); + expect(command).toContain("/opt/bin"); + expect(command).toContain("/usr/pkg/bin"); + expect(command).toContain(":$PATH; command -v tmux"); + }); + + it("wraps tmux invocations with the same path", () => { + expect(tmuxCommand("list-sessions")).toMatch( + /^PATH=.*:\$PATH; tmux list-sessions$/, + ); + }); +}); diff --git a/src/backend/ssh/tmux-helper.ts b/src/backend/ssh/tmux-helper.ts index 31677ff6..54a16afb 100644 --- a/src/backend/ssh/tmux-helper.ts +++ b/src/backend/ssh/tmux-helper.ts @@ -14,6 +14,21 @@ export interface TmuxDetectionResult { sessions: TmuxSessionInfo[]; } +const TMUX_PATH_DIRS = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/opt/bin", + "/usr/pkg/bin", +]; + +export function withTmuxPath(command: string): string { + return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`; +} + +export function tmuxCommand(args: string): string { + return withTmuxPath(`tmux ${args}`); +} + /** * Run a command on the remote host via a separate exec channel. * Returns stdout as a string. Does not pollute the interactive shell. @@ -54,7 +69,7 @@ export function execCommand(conn: Client, command: string): Promise { */ export async function detectTmux(conn: Client): Promise { try { - await execCommand(conn, "command -v tmux"); + await execCommand(conn, withTmuxPath("command -v tmux")); } catch { return { available: false, sessions: [] }; } @@ -63,7 +78,9 @@ export async function detectTmux(conn: Client): Promise { try { const output = await execCommand( conn, - `tmux list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`, + tmuxCommand( + `list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`, + ), ); if (output) { sessions = output @@ -126,7 +143,7 @@ export async function waitForTmuxSession( try { await execCommand( conn, - `tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`, + tmuxCommand(`has-session -t ${shellEscape(sessionName)} 2>/dev/null`), ); return sessionName; } catch { @@ -148,10 +165,10 @@ export function attachOrCreateTmuxSession( ): void { let command: string; if (existingSessionName) { - command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`; + command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`; } else { const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : ""; - command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`; + command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`; } sshLogger.info("Writing tmux command to shell", { @@ -172,7 +189,9 @@ export async function queryNewestTmuxSession( try { const output = await execCommand( conn, - `tmux list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`, + tmuxCommand( + `list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`, + ), ); return output || null; } catch { diff --git a/src/backend/ssh/tmux-monitor.ts b/src/backend/ssh/tmux-monitor.ts index 5d576282..701b1b94 100644 --- a/src/backend/ssh/tmux-monitor.ts +++ b/src/backend/ssh/tmux-monitor.ts @@ -10,15 +10,17 @@ import { tmuxSessionTags, users } from "../database/db/schema.js"; import { logAudit, getRequestMeta } from "../utils/audit-logger.js"; import { sshLogger } from "../utils/logger.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { resolveHostById, checkHostAccess } from "./host-resolver.js"; import { createJumpHostChain } from "./jump-host-chain.js"; +import { applyAgentAuth } from "./terminal-auth-helpers.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { withConnection } from "./ssh-connection-pool.js"; -import { execCommand } from "./tmux-helper.js"; +import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js"; import { SEP, parseSessions, @@ -81,22 +83,26 @@ async function buildSshConfig(host: SSHHost): Promise { } base.password = host.password; } else if (host.authType === "key") { - if (!host.key || !host.key.includes("-----BEGIN")) { + if (!host.key) { throw new Error(`No valid SSH key available for host ${host.ip}`); } - const cleanKey = host.key - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - (base as Record).privateKey = Buffer.from( - cleanKey, - "utf8", + (base as Record).privateKey = preparePrivateKeyForSSH2( + host.key, + host.keyPassword, ); if (host.keyPassword) { (base as Record).passphrase = host.keyPassword; } } else if (host.authType === "none") { // no credentials needed + } else if (host.authType === "agent") { + const result = await applyAgentAuth( + base as Record, + host.terminalConfig as unknown as Record | undefined, + ); + if ("error" in result) { + throw new Error(result.error); + } } else { // opkssh and other interactive flows are not supported by this module throw new Error( @@ -222,7 +228,7 @@ async function withHostConnection( async function tmuxAvailable(conn: Client): Promise { try { - await execCommand(conn, "command -v tmux"); + await execCommand(conn, withTmuxPath("command -v tmux")); return true; } catch { return false; @@ -238,15 +244,21 @@ async function runTmuxList(conn: Client, command: string): Promise { } function listSessionsCmd(): string { - return `tmux list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`; + return tmuxCommand( + `list-sessions -F "#{session_name}${SEP}#{session_created}${SEP}#{session_activity}${SEP}#{session_attached}" 2>/dev/null`, + ); } function listWindowsCmd(): string { - return `tmux list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`; + return tmuxCommand( + `list-windows -a -F "#{session_name}${SEP}#{window_index}${SEP}#{window_active}${SEP}#{window_name}" 2>/dev/null`, + ); } function listPanesCmd(): string { - return `tmux list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`; + return tmuxCommand( + `list-panes -a -F "#{session_name}${SEP}#{window_index}${SEP}#{pane_id}${SEP}#{pane_index}${SEP}#{pane_pid}${SEP}#{pane_active}${SEP}#{pane_width}${SEP}#{pane_height}${SEP}#{pane_current_command}${SEP}#{pane_current_path}${SEP}#{pane_title}" 2>/dev/null`, + ); } async function listPanesRaw(conn: Client): Promise { @@ -507,7 +519,9 @@ app.post("/tmux_monitor/:hostId/focus", async (req, res) => { // containing the pane. execCommand( conn, - `tmux select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`, + tmuxCommand( + `select-window -t ${shellEscape(paneId)} \\; select-pane -t ${shellEscape(paneId)}`, + ), ), ); res.json({ ok: true }); @@ -528,7 +542,7 @@ app.post("/tmux_monitor/:hostId/sessions", async (req, res) => { try { await withHostConnection(host, (conn) => - execCommand(conn, `tmux new-session -d -s ${shellEscape(name)}`), + execCommand(conn, tmuxCommand(`new-session -d -s ${shellEscape(name)}`)), ); sshLogger.info("tmux session created", { operation: "tmux_session_create", @@ -563,7 +577,10 @@ app.post("/tmux_monitor/:hostId/windows", async (req, res) => { try { await withHostConnection(host, (conn) => - execCommand(conn, `tmux new-window -t ${shellEscape(`=${sessionName}`)}`), + execCommand( + conn, + tmuxCommand(`new-window -t ${shellEscape(`=${sessionName}`)}`), + ), ); sshLogger.info("tmux window created", { operation: "tmux_window_create", @@ -599,7 +616,9 @@ app.post("/tmux_monitor/:hostId/rename", async (req, res) => { await withHostConnection(host, (conn) => execCommand( conn, - `tmux rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`, + tmuxCommand( + `rename-session -t ${shellEscape(`=${sessionName}`)} ${shellEscape(newName)}`, + ), ), ); await getDb() @@ -651,7 +670,7 @@ app.post("/tmux_monitor/:hostId/kill", async (req, res) => { await withHostConnection(host, (conn) => execCommand( conn, - `tmux kill-session -t ${shellEscape(`=${sessionName}`)}`, + tmuxCommand(`kill-session -t ${shellEscape(`=${sessionName}`)}`), ), ); await getDb() @@ -698,7 +717,9 @@ app.post("/tmux_monitor/:hostId/kill-window", async (req, res) => { await withHostConnection(host, (conn) => execCommand( conn, - `tmux kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`, + tmuxCommand( + `kill-window -t ${shellEscape(`=${sessionName}:${windowIndex}`)}`, + ), ), ); sshLogger.info("tmux window killed", { @@ -736,7 +757,7 @@ app.post("/tmux_monitor/:hostId/kill-pane", async (req, res) => { try { await withHostConnection(host, (conn) => - execCommand(conn, `tmux kill-pane -t ${shellEscape(paneId)}`), + execCommand(conn, tmuxCommand(`kill-pane -t ${shellEscape(paneId)}`)), ); sshLogger.info("tmux pane killed", { operation: "tmux_pane_kill", @@ -774,7 +795,9 @@ app.post("/tmux_monitor/:hostId/split", async (req, res) => { await withHostConnection(host, (conn) => execCommand( conn, - `tmux split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`, + tmuxCommand( + `split-window ${direction} -t ${shellEscape(paneId)} -c ${shellEscape("#{pane_current_path}")}`, + ), ), ); sshLogger.info("tmux pane split", { @@ -822,7 +845,9 @@ app.get("/tmux_monitor/:hostId/search", async (req, res) => { try { const output = await execCommand( conn, - `tmux capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`, + tmuxCommand( + `capture-pane -p -J -t ${shellEscape(pane.id)} -S -${SEARCH_HISTORY_LINES} 2>/dev/null | grep -n -i -F -- ${shellEscape(query)} | head -${MAX_MATCHES_PER_PANE}`, + ), ); const lines = output.split("\n").filter(Boolean); if (lines.length >= MAX_MATCHES_PER_PANE) truncated = true; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index a5f95933..4580ef6c 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -32,6 +32,7 @@ import { createSocks5Connection } from "../utils/socks5-helper.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; import { withConnection } from "./ssh-connection-pool.js"; +import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js"; import { applyAuthOptions, bindForwardIn, @@ -94,6 +95,66 @@ const activeTunnelProcesses = new Map(); const pendingTunnelOperations = new Map>(); const tunnelStatusClients = new Set(); +const INTERNAL_HOST_API_BASE_URL = "http://localhost:30001/host/db/host"; +const AUTOSTART_FETCH_RETRIES = 6; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function describeAxiosError(error: unknown): string { + if (axios.isAxiosError(error)) { + return error.response + ? `${error.response.status} ${error.response.statusText}` + : error.message; + } + + return error instanceof Error ? error.message : "Unknown error"; +} + +async function fetchInternalHosts( + path: "internal" | "internal/all", + internalAuthToken: string, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= AUTOSTART_FETCH_RETRIES; attempt++) { + try { + const response = await axios.get( + `${INTERNAL_HOST_API_BASE_URL}/${path}`, + { + headers: { + "Content-Type": "application/json", + "X-Internal-Auth-Token": internalAuthToken, + }, + timeout: 5000, + }, + ); + return response.data || []; + } catch (error) { + lastError = error; + if (attempt === AUTOSTART_FETCH_RETRIES) { + break; + } + + const retryDelayMs = Math.min(500 * 2 ** (attempt - 1), 5000); + tunnelLogger.warn("Internal host API unavailable, retrying", { + operation: "tunnel_autostart_fetch_retry", + path, + attempt, + maxAttempts: AUTOSTART_FETCH_RETRIES, + retryDelayMs, + error: describeAxiosError(error), + }); + await sleep(retryDelayMs); + } + } + + throw new Error( + `Failed to fetch ${path} hosts after ${AUTOSTART_FETCH_RETRIES} attempts: ${describeAxiosError(lastError)}`, + ); +} + type ActiveTunnelRuntime = { sourceClient: Client; endpointClient?: Client; @@ -106,6 +167,24 @@ type ActiveTunnelRuntime = { const activeTunnelRuntimes = new Map(); +function findHostByTunnelEndpoint( + hosts: SSHHost[], + endpointHost?: string, +): SSHHost | undefined { + const value = endpointHost?.trim(); + if (!value) return undefined; + + return hosts.find((host) => { + const userAtIp = `${host.username}@${host.ip}`; + return ( + String(host.id) === value || + host.name === value || + host.ip === value || + userAtIp === value + ); + }); +} + function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if ( status.status === CONNECTION_STATES.CONNECTED && @@ -550,6 +629,12 @@ function isSingleHostTunnel(tunnelConfig: TunnelConfig): boolean { return false; } +function shouldEstablishDirectTunnel(tunnelConfig: TunnelConfig): boolean { + if (isSingleHostTunnel(tunnelConfig)) return true; + const mode = getTunnelMode(tunnelConfig); + return mode !== "remote" && !tunnelConfig.endpointUsername; +} + async function establishDirectTunnel( sourceClient: Client, tunnelConfig: TunnelConfig, @@ -558,7 +643,8 @@ async function establishDirectTunnel( const mode = getTunnelMode(tunnelConfig); const bindHost = getTunnelBindHost(tunnelConfig); const sourcePort = tunnelConfig.sourcePort; - const targetHost = tunnelConfig.targetHost || "127.0.0.1"; + const targetHost = + tunnelConfig.targetHost || tunnelConfig.endpointHost || "127.0.0.1"; const targetPort = tunnelConfig.endpointPort; if (mode === "remote") { @@ -993,6 +1079,7 @@ async function connectSSHTunnel( } if ( + !shouldEstablishDirectTunnel(tunnelConfig) && resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password ) { @@ -1013,6 +1100,7 @@ async function connectSSHTunnel( } if ( + !shouldEstablishDirectTunnel(tunnelConfig) && resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey ) { @@ -1166,7 +1254,7 @@ async function connectSSHTunnel( ); } - if (isSingleHostTunnel(tunnelConfig)) { + if (shouldEstablishDirectTunnel(tunnelConfig)) { await establishDirectTunnel(conn, tunnelConfig); } else { await establishManagedS2STunnel( @@ -1300,37 +1388,33 @@ async function connectSSHTunnel( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey ) { - if ( - !resolvedSourceCredentials.sshKey.includes("-----BEGIN") || - !resolvedSourceCredentials.sshKey.includes("-----END") - ) { + try { + connOptions.privateKey = preparePrivateKeyForSSH2( + resolvedSourceCredentials.sshKey, + resolvedSourceCredentials.keyPassword, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Invalid SSH key format"; tunnelLogger.error( - `Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`, + `Invalid SSH key format for tunnel '${tunnelName}': ${message}`, undefined, { operation: "tunnel_invalid_ssh_key_format", tunnelName, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, keyType: resolvedSourceCredentials.keyType, - hasBeginMarker: - resolvedSourceCredentials.sshKey.includes("-----BEGIN"), - hasEndMarker: resolvedSourceCredentials.sshKey.includes("-----END"), }, ); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, - reason: "Invalid SSH key format", + reason: message, }); tunnelConnecting.delete(tunnelName); return; } - const cleanKey = resolvedSourceCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connOptions.privateKey = Buffer.from(cleanKey, "utf8"); if (resolvedSourceCredentials.keyPassword) { connOptions.passphrase = resolvedSourceCredentials.keyPassword; } @@ -1482,11 +1566,15 @@ async function killRemoteTunnelByMarker( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey ) { - if ( - !resolvedSourceCredentials.sshKey.includes("-----BEGIN") || - !resolvedSourceCredentials.sshKey.includes("-----END") - ) { - callback(new Error("Invalid SSH key format")); + try { + preparePrivateKeyForSSH2( + resolvedSourceCredentials.sshKey, + resolvedSourceCredentials.keyPassword, + ); + } catch (error) { + callback( + error instanceof Error ? error : new Error("Invalid SSH key format"), + ); return; } } @@ -1542,11 +1630,10 @@ async function killRemoteTunnelByMarker( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey ) { - const cleanKey = resolvedSourceCredentials.sshKey - .trim() - .replace(/\r\n/g, "\n") - .replace(/\r/g, "\n"); - connOptions.privateKey = Buffer.from(cleanKey, "utf8"); + connOptions.privateKey = preparePrivateKeyForSSH2( + resolvedSourceCredentials.sshKey, + resolvedSourceCredentials.keyPassword, + ); if (resolvedSourceCredentials.keyPassword) { connOptions.passphrase = resolvedSourceCredentials.keyPassword; } @@ -1931,51 +2018,56 @@ app.post( ); const allHosts: SSHHost[] = allHostsResponse.data || []; - const endpointHost = allHosts.find( - (h) => - h.name === tunnelConfig.endpointHost || - `${h.username}@${h.ip}` === tunnelConfig.endpointHost, + const endpointHost = findHostByTunnelEndpoint( + allHosts, + tunnelConfig.endpointHost, ); if (!endpointHost) { - throw new Error( - `Endpoint host '${tunnelConfig.endpointHost}' not found in database`, - ); - } - - tunnelConfig.endpointIP = endpointHost.ip; - tunnelConfig.endpointSSHPort = endpointHost.port; - tunnelConfig.endpointUsername = endpointHost.username; - tunnelConfig.endpointAuthMethod = endpointHost.authType; - tunnelConfig.endpointKeyType = endpointHost.keyType; - tunnelConfig.endpointCredentialId = endpointHost.credentialId; - tunnelConfig.endpointUserId = endpointHost.userId; - - // Resolve credentials server-side instead of from HTTP response - if (endpointHost.id && endpointHost.userId) { - try { - const { resolveHostById } = await import("./host-resolver.js"); - const resolved = await resolveHostById( - endpointHost.id, - endpointHost.userId, + if (getTunnelMode(tunnelConfig) !== "remote") { + tunnelConfig.endpointIP = + tunnelConfig.endpointIP || tunnelConfig.endpointHost; + } else { + throw new Error( + `Endpoint host '${tunnelConfig.endpointHost}' not found in database`, ); - if (resolved) { - tunnelConfig.endpointPassword = resolved.password; - tunnelConfig.endpointSSHKey = resolved.key; - tunnelConfig.endpointKeyPassword = resolved.keyPassword; + } + } else { + tunnelConfig.endpointIP = endpointHost.ip; + tunnelConfig.endpointSSHPort = endpointHost.port; + tunnelConfig.endpointUsername = endpointHost.username; + tunnelConfig.endpointAuthMethod = endpointHost.authType; + tunnelConfig.endpointKeyType = endpointHost.keyType; + tunnelConfig.endpointCredentialId = endpointHost.credentialId; + tunnelConfig.endpointUserId = endpointHost.userId; + + // Resolve credentials server-side instead of from HTTP response + if (endpointHost.id && endpointHost.userId) { + try { + const { resolveHostById } = + await import("./host-resolver.js"); + const resolved = await resolveHostById( + endpointHost.id, + endpointHost.userId, + ); + if (resolved) { + tunnelConfig.endpointPassword = resolved.password; + tunnelConfig.endpointSSHKey = resolved.key; + tunnelConfig.endpointKeyPassword = resolved.keyPassword; + } + } catch (credError) { + tunnelLogger.warn( + "Failed to resolve endpoint credentials from DB", + { + operation: "tunnel_endpoint_credential_resolve", + endpointHostId: endpointHost.id, + error: + credError instanceof Error + ? credError.message + : "Unknown", + }, + ); } - } catch (credError) { - tunnelLogger.warn( - "Failed to resolve endpoint credentials from DB", - { - operation: "tunnel_endpoint_credential_resolve", - endpointHostId: endpointHost.id, - error: - credError instanceof Error - ? credError.message - : "Unknown", - }, - ); } } } catch (resolveError) { @@ -2237,28 +2329,14 @@ async function initializeAutoStartTunnels(): Promise { const systemCrypto = SystemCrypto.getInstance(); const internalAuthToken = await systemCrypto.getInternalAuthToken(); - const autostartResponse = await axios.get( - "http://localhost:30001/host/db/host/internal", - { - headers: { - "Content-Type": "application/json", - "X-Internal-Auth-Token": internalAuthToken, - }, - }, + const autostartHosts = await fetchInternalHosts( + "internal", + internalAuthToken, ); - - const allHostsResponse = await axios.get( - "http://localhost:30001/host/db/host/internal/all", - { - headers: { - "Content-Type": "application/json", - "X-Internal-Auth-Token": internalAuthToken, - }, - }, + const allHosts = await fetchInternalHosts( + "internal/all", + internalAuthToken, ); - - const autostartHosts: SSHHost[] = autostartResponse.data || []; - const allHosts: SSHHost[] = allHostsResponse.data || []; const autoStartTunnels: TunnelConfig[] = []; tunnelLogger.info( `Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`, @@ -2268,13 +2346,15 @@ async function initializeAutoStartTunnels(): Promise { if (host.enableTunnel && host.tunnelConnections) { for (const tunnelConnection of host.tunnelConnections) { if (tunnelConnection.autoStart) { - const endpointHost = allHosts.find( - (h) => - h.name === tunnelConnection.endpointHost || - `${h.username}@${h.ip}` === tunnelConnection.endpointHost, + const endpointHost = findHostByTunnelEndpoint( + allHosts, + tunnelConnection.endpointHost, ); + const mode = + tunnelConnection.mode || tunnelConnection.tunnelType || "remote"; + const allowDirectTarget = mode !== "remote"; - if (endpointHost) { + if (endpointHost || allowDirectTarget) { const tunnelIndex = host.tunnelConnections.indexOf(tunnelConnection); const tunnelConfig: TunnelConfig = { @@ -2287,10 +2367,7 @@ async function initializeAutoStartTunnels(): Promise { tunnelConnection.endpointPort, ), scope: tunnelConnection.scope || "s2s", - mode: - tunnelConnection.mode || - tunnelConnection.tunnelType || - "remote", + mode, bindHost: tunnelConnection.bindHost, targetHost: tunnelConnection.targetHost, tunnelType: tunnelConnection.tunnelType || "remote", @@ -2304,16 +2381,18 @@ async function initializeAutoStartTunnels(): Promise { sourceKeyType: host.keyType, sourceCredentialId: host.credentialId, sourceUserId: host.userId, - endpointIP: endpointHost.ip, - endpointSSHPort: endpointHost.port, - endpointUsername: endpointHost.username, + endpointIP: endpointHost?.ip || tunnelConnection.endpointHost, + endpointSSHPort: endpointHost?.port || 22, + endpointUsername: endpointHost?.username || "", endpointHost: tunnelConnection.endpointHost, endpointAuthMethod: - tunnelConnection.endpointAuthType || endpointHost.authType, + tunnelConnection.endpointAuthType || + endpointHost?.authType || + "none", endpointKeyType: - tunnelConnection.endpointKeyType || endpointHost.keyType, - endpointCredentialId: endpointHost.credentialId, - endpointUserId: endpointHost.userId, + tunnelConnection.endpointKeyType || endpointHost?.keyType, + endpointCredentialId: endpointHost?.credentialId, + endpointUserId: endpointHost?.userId, sourcePort: tunnelConnection.sourcePort, endpointPort: tunnelConnection.endpointPort, maxRetries: tunnelConnection.maxRetries, diff --git a/src/backend/ssh/vault-oidc-auth.ts b/src/backend/ssh/vault-oidc-auth.ts new file mode 100644 index 00000000..29474d96 --- /dev/null +++ b/src/backend/ssh/vault-oidc-auth.ts @@ -0,0 +1,216 @@ +// Orchestrates the interactive Vault OIDC login that yields a signed, +// short-lived SSH certificate. Mirrors the OPKSSH session manager but is +// driven entirely over Vault's HTTP API (no external binary). +// +// Lifecycle: +// - startVaultAuth(): generate ephemeral keypair, ask Vault for an OIDC +// auth_url, stash a pending session keyed by the Vault-issued `state`, +// and send the auth_url to the browser over the terminal WebSocket. +// - The browser completes OIDC; the IdP redirects to VAULT_OIDC_CALLBACK_PATH. +// - completeVaultAuth(): exchange the code for a Vault token, sign the +// ephemeral key, cache the cert, and notify the browser to reconnect. + +import { WebSocket } from "ws"; +import { eq } from "drizzle-orm"; +import { getDb } from "../database/db/index.js"; +import { hosts, vaultProfiles } from "../database/db/schema.js"; +import { sshLogger } from "../utils/logger.js"; +import { + type VaultProfileConfig, + generateEphemeralKeyPair, + startVaultOidc, + completeVaultOidc, + signWithVault, + storeVaultCert, +} from "./vault-signer-auth.js"; + +export const VAULT_OIDC_CALLBACK_PATH = "/vault/oidc/callback"; + +const AUTH_TIMEOUT = 5 * 60 * 1000; + +interface VaultAuthSession { + state: string; + userId: string; + hostId: number; + profile: VaultProfileConfig; + clientNonce: string; + ephemeralPrivateKey: string; + ephemeralPublicKey: string; + ws: WebSocket; + timeout: NodeJS.Timeout; +} + +// Keyed by the Vault-issued OIDC `state` so the unauthenticated browser callback +// can correlate back to the originating session. +const sessions = new Map(); + +function rowToProfileConfig(row: Record): VaultProfileConfig { + return { + id: row.id as number, + vaultAddr: row.vaultAddr as string, + vaultNamespace: (row.vaultNamespace as string | null) ?? null, + oidcMount: (row.oidcMount as string | null) ?? null, + oidcRole: (row.oidcRole as string | null) ?? null, + sshMount: (row.sshMount as string | null) ?? null, + sshRole: row.sshRole as string, + validPrincipals: (row.validPrincipals as string | null) ?? null, + keyType: (row.keyType as string | null) ?? null, + }; +} + +/** Load the Vault profile referenced by a host, or null if not configured. */ +export async function loadVaultProfileForHost( + hostId: number, +): Promise { + const db = getDb(); + const hostRows = await db + .select() + .from(hosts) + .where(eq(hosts.id, hostId)) + .limit(1); + if (!hostRows.length || hostRows[0].vaultProfileId == null) return null; + + const profileRows = await db + .select() + .from(vaultProfiles) + .where(eq(vaultProfiles.id, hostRows[0].vaultProfileId as number)) + .limit(1); + if (!profileRows.length) return null; + + return rowToProfileConfig(profileRows[0] as Record); +} + +/** + * Begin an interactive Vault OIDC login and send the auth URL to the browser. + */ +export async function startVaultAuth( + userId: string, + hostId: number, + profile: VaultProfileConfig, + ws: WebSocket, + requestOrigin: string, +): Promise { + const ephemeral = generateEphemeralKeyPair(profile.keyType); + const redirectUri = `${requestOrigin}${VAULT_OIDC_CALLBACK_PATH}`; + + const { authUrl, state, clientNonce } = await startVaultOidc( + profile, + redirectUri, + ); + + const existing = sessions.get(state); + if (existing) clearTimeout(existing.timeout); + + const timeout = setTimeout(() => { + sessions.delete(state); + }, AUTH_TIMEOUT); + + sessions.set(state, { + state, + userId, + hostId, + profile, + clientNonce, + ephemeralPrivateKey: ephemeral.privateKey, + ephemeralPublicKey: ephemeral.publicKey, + ws, + timeout, + }); + + sshLogger.info("Started Vault OIDC auth session", { + operation: "vault_oidc_start", + userId, + hostId, + profileId: profile.id, + }); + + ws.send( + JSON.stringify({ + type: "vault_auth_url", + hostId, + url: authUrl, + }), + ); +} + +/** + * Complete a Vault OIDC login from the browser callback: exchange the code for a + * Vault token, sign the ephemeral key, cache the certificate, and notify the + * browser to resume the SSH connection. + */ +export async function completeVaultAuth( + state: string, + code: string, +): Promise<{ ok: boolean; error?: string }> { + const session = sessions.get(state); + if (!session) { + return { ok: false, error: "No matching Vault authentication session" }; + } + + try { + const token = await completeVaultOidc(session.profile, { + state, + code, + clientNonce: session.clientNonce, + }); + const signedCert = await signWithVault( + session.profile, + token, + session.ephemeralPublicKey, + ); + const expiresAt = await storeVaultCert( + session.userId, + session.profile.id, + session.ephemeralPrivateKey, + signedCert, + ); + + sshLogger.success("Completed Vault OIDC auth and cached certificate", { + operation: "vault_oidc_complete", + userId: session.userId, + hostId: session.hostId, + profileId: session.profile.id, + }); + + session.ws.send( + JSON.stringify({ + type: "vault_completed", + hostId: session.hostId, + expiresAt, + }), + ); + return { ok: true }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + sshLogger.error("Vault OIDC completion failed", e, { + operation: "vault_oidc_complete_error", + userId: session.userId, + hostId: session.hostId, + }); + try { + session.ws.send( + JSON.stringify({ + type: "vault_error", + hostId: session.hostId, + error: msg, + }), + ); + } catch { + // ws may be closed + } + return { ok: false, error: msg }; + } finally { + clearTimeout(session.timeout); + sessions.delete(state); + } +} + +/** Cancel a pending Vault auth session (e.g. user dismissed the dialog). */ +export function cancelVaultAuthByHost(userId: string, hostId: number): void { + for (const [state, s] of sessions) { + if (s.userId === userId && s.hostId === hostId) { + clearTimeout(s.timeout); + sessions.delete(state); + } + } +} diff --git a/src/backend/ssh/vault-signer-auth.ts b/src/backend/ssh/vault-signer-auth.ts new file mode 100644 index 00000000..457dcd9e --- /dev/null +++ b/src/backend/ssh/vault-signer-auth.ts @@ -0,0 +1,167 @@ +// HashiCorp Vault SSH signer authentication. +// +// Flow (mirrors the OPKSSH subsystem, but Vault-driven over HTTP): +// 1. Generate an ephemeral SSH keypair (never persisted long-term). +// 2. The user authenticates to Vault via an interactive OIDC flow +// (auth//oidc/auth_url -> browser -> auth//oidc/callback), +// yielding a short-lived Vault token. +// 3. Vault's SSH secrets engine signs the ephemeral public key +// (/sign/) -> short-lived OpenSSH certificate. +// 4. The ephemeral private key + certificate are cached per-user (encrypted) +// until the certificate expires, then used to connect via setupOPKSSHCertAuth. +// +// No Vault tokens, AppRole secrets, or long-lived private keys are ever stored. +// Vault connection SETTINGS live in shareable vault_profiles rows. +// +// The pure (DB-free) signing/OIDC/cert logic lives in vault-signer-core.ts and +// is re-exported here so callers have a single import surface. + +import { and, eq } from "drizzle-orm"; +import { getDb } from "../database/db/index.js"; +import { vaultTokens } from "../database/db/schema.js"; +import { UserCrypto } from "../utils/user-crypto.js"; +import { FieldCrypto } from "../utils/field-crypto.js"; +import { parseCertValidBefore } from "./vault-signer-core.js"; + +export type { + VaultProfileConfig, + EphemeralKeyPair, +} from "./vault-signer-core.js"; +export { + generateEphemeralKeyPair, + startVaultOidc, + completeVaultOidc, + signWithVault, + parseCertValidBefore, +} from "./vault-signer-core.js"; + +// Re-sign once we're within this many seconds of expiry. +const EXPIRY_SKEW_SECONDS = 60; + +function cacheRecordId(userId: string, profileId: number): string { + return `vault-${userId}-${profileId}`; +} + +/** + * Store an ephemeral private key + signed certificate for a user/profile. + * Returns the expiry as an ISO string. + */ +export async function storeVaultCert( + userId: string, + profileId: number, + privateKey: string, + signedCert: string, +): Promise { + const db = getDb(); + const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + if (!userDataKey) { + throw new Error("User data key not found"); + } + + const validBefore = parseCertValidBefore(signedCert); + const expiresMs = + validBefore > 0 ? validBefore * 1000 : Date.now() + 5 * 60 * 1000; + const expiresAt = new Date(expiresMs).toISOString(); + + const recordId = cacheRecordId(userId, profileId); + const encryptedCert = FieldCrypto.encryptField( + signedCert, + userDataKey, + recordId, + "ssh_cert", + ); + const encryptedKey = FieldCrypto.encryptField( + privateKey, + userDataKey, + recordId, + "private_key", + ); + + await db + .insert(vaultTokens) + .values({ + userId, + profileId, + sshCert: encryptedCert, + privateKey: encryptedKey, + expiresAt, + }) + .onConflictDoUpdate({ + target: [vaultTokens.userId, vaultTokens.profileId], + set: { + sshCert: encryptedCert, + privateKey: encryptedKey, + expiresAt, + createdAt: new Date().toISOString(), + }, + }); + + return expiresAt; +} + +/** + * Return a cached, still-valid ephemeral key + certificate for a user/profile, + * or null if none exists or it has (nearly) expired. + */ +export async function getVaultCert( + userId: string, + profileId: number, +): Promise<{ privateKey: string; sshCert: string } | null> { + const db = getDb(); + const rows = await db + .select() + .from(vaultTokens) + .where( + and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), + ) + .limit(1); + + if (!rows || rows.length === 0) return null; + const row = rows[0]; + + const expiresMs = new Date(row.expiresAt).getTime(); + if (expiresMs - EXPIRY_SKEW_SECONDS * 1000 < Date.now()) { + await deleteVaultCert(userId, profileId); + return null; + } + + const userDataKey = UserCrypto.getInstance().getUserDataKey(userId); + if (!userDataKey) { + throw new Error("User data key not found"); + } + + const recordId = cacheRecordId(userId, profileId); + const sshCert = FieldCrypto.decryptField( + row.sshCert, + userDataKey, + recordId, + "ssh_cert", + ); + const privateKey = FieldCrypto.decryptField( + row.privateKey, + userDataKey, + recordId, + "private_key", + ); + + await db + .update(vaultTokens) + .set({ lastUsed: new Date().toISOString() }) + .where( + and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), + ); + + return { privateKey, sshCert }; +} + +export async function deleteVaultCert( + userId: string, + profileId: number, +): Promise { + const db = getDb(); + await db + .delete(vaultTokens) + .where( + and(eq(vaultTokens.userId, userId), eq(vaultTokens.profileId, profileId)), + ); +} diff --git a/src/backend/ssh/vault-signer-core.test.ts b/src/backend/ssh/vault-signer-core.test.ts new file mode 100644 index 00000000..9bdb9f08 --- /dev/null +++ b/src/backend/ssh/vault-signer-core.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import os from "os"; +import path from "path"; +import ssh2Pkg from "ssh2"; +import { + generateEphemeralKeyPair, + parseCertValidBefore, + startVaultOidc, + completeVaultOidc, + signWithVault, + type VaultProfileConfig, +} from "./vault-signer-core.js"; + +const { utils: ssh2Utils } = ssh2Pkg; + +describe("generateEphemeralKeyPair", () => { + for (const keyType of [ + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ssh-rsa", + ] as const) { + it(`generates a parseable ${keyType} keypair`, () => { + const pair = generateEphemeralKeyPair(keyType); + expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY"); + expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType); + + // Both halves must be parseable by the same library that signs/connects. + const priv = ssh2Utils.parseKey(pair.privateKey); + expect(priv instanceof Error).toBe(false); + const pub = ssh2Utils.parseKey(pair.publicKey); + expect(pub instanceof Error).toBe(false); + }); + } + + it("defaults to ed25519 for unknown key types", () => { + const pair = generateEphemeralKeyPair("nonsense"); + expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true); + }); +}); + +describe("parseCertValidBefore", () => { + let cert = ""; + let signedAt = 0; + let haveSshKeygen = true; + + beforeAll(() => { + const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-")); + try { + execFileSync("ssh-keygen", [ + "-t", + "ed25519", + "-f", + `${dir}/ca`, + "-N", + "", + "-q", + ]); + execFileSync("ssh-keygen", [ + "-t", + "ed25519", + "-f", + `${dir}/user`, + "-N", + "", + "-q", + ]); + signedAt = Math.floor(Date.now() / 1000); + execFileSync("ssh-keygen", [ + "-s", + `${dir}/ca`, + "-I", + "test-id", + "-n", + "root", + "-V", + "+60m", + `${dir}/user.pub`, + ]); + cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim(); + } catch { + haveSshKeygen = false; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reads valid_before from a real ssh-keygen certificate", () => { + if (!haveSshKeygen) { + console.warn("ssh-keygen unavailable; skipping real-cert parse test"); + return; + } + const validBefore = parseCertValidBefore(cert); + // -V +60m => valid_before is roughly signedAt + 3600 (start rounds to minute) + expect(validBefore).toBeGreaterThan(signedAt + 3300); + expect(validBefore).toBeLessThan(signedAt + 3900); + }); + + it("returns 0 for malformed input", () => { + expect(parseCertValidBefore("")).toBe(0); + expect(parseCertValidBefore("not-a-cert")).toBe(0); + expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0); + }); +}); + +describe("Vault HTTP flow (mocked fetch)", () => { + const profile: VaultProfileConfig = { + id: 1, + vaultAddr: "https://vault.example.com:8200/", + vaultNamespace: "team-a", + oidcMount: "oidc", + oidcRole: "developer", + sshMount: "ssh-client-signer", + sshRole: "my-role", + validPrincipals: "root,deploy", + keyType: "ssh-ed25519", + }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockFetch( + impl: ( + url: string, + init: RequestInit, + ) => { status?: number; body: unknown }, + ) { + const calls: Array<{ url: string; init: RequestInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + const { status = 200, body } = impl(url, init); + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body), + } as Response; + }), + ); + return calls; + } + + it("startVaultOidc posts auth_url and extracts state", async () => { + const calls = mockFetch(() => ({ + body: { + data: { + auth_url: + "https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n", + }, + }, + })); + + const result = await startVaultOidc( + profile, + "https://termix/vault/oidc/callback", + ); + + expect(result.state).toBe("ST-abc123"); + expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe( + "https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url", + ); + expect(calls[0].init.method).toBe("POST"); + const headers = calls[0].init.headers as Record; + expect(headers["X-Vault-Namespace"]).toBe("team-a"); + const body = JSON.parse(calls[0].init.body as string); + expect(body).toMatchObject({ + role: "developer", + redirect_uri: "https://termix/vault/oidc/callback", + }); + expect(body.client_nonce).toBe(result.clientNonce); + }); + + it("completeVaultOidc returns the client token", async () => { + const calls = mockFetch(() => ({ + body: { auth: { client_token: "hvs.TESTTOKEN" } }, + })); + + const token = await completeVaultOidc(profile, { + state: "ST-abc123", + code: "auth-code", + clientNonce: "nonce123", + }); + + expect(token).toBe("hvs.TESTTOKEN"); + const url = new URL(calls[0].url); + expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback"); + expect(url.searchParams.get("state")).toBe("ST-abc123"); + expect(url.searchParams.get("code")).toBe("auth-code"); + expect(url.searchParams.get("client_nonce")).toBe("nonce123"); + expect(calls[0].init.method).toBe("GET"); + }); + + it("signWithVault posts the public key and returns signed_key", async () => { + const calls = mockFetch(() => ({ + body: { + data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" }, + }, + })); + + const cert = await signWithVault( + profile, + "hvs.TESTTOKEN", + "ssh-ed25519 AAAApub comment", + ); + + expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert"); + expect(calls[0].url).toBe( + "https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role", + ); + const headers = calls[0].init.headers as Record; + expect(headers["X-Vault-Token"]).toBe("hvs.TESTTOKEN"); + expect(headers["X-Vault-Namespace"]).toBe("team-a"); + const body = JSON.parse(calls[0].init.body as string); + expect(body).toMatchObject({ + public_key: "ssh-ed25519 AAAApub comment", + cert_type: "user", + valid_principals: "root,deploy", + }); + }); + + it("surfaces Vault error messages", async () => { + mockFetch(() => ({ + status: 400, + body: { errors: ["role not found", "permission denied"] }, + })); + + await expect( + signWithVault(profile, "tok", "ssh-ed25519 AAAA"), + ).rejects.toThrow(/role not found; permission denied/); + }); +}); + +// Live integration against a real Vault (e.g. `vault server -dev` in Docker). +// Runs only when VAULT_ADDR + VAULT_TOKEN are set and the SSH signer mount +// (VAULT_SSH_MOUNT/VAULT_SSH_ROLE) has been configured by the test harness. +describe("Vault live signing", () => { + const addr = process.env.VAULT_ADDR; + const token = process.env.VAULT_TOKEN; + const run = !!addr && !!token; + + it.skipIf(!run)("signs an ephemeral key against a real Vault", async () => { + const profile: VaultProfileConfig = { + id: 99, + vaultAddr: addr!, + sshMount: process.env.VAULT_SSH_MOUNT || "ssh-client-signer", + sshRole: process.env.VAULT_SSH_ROLE || "my-role", + validPrincipals: "root", + keyType: "ssh-ed25519", + }; + + const pair = generateEphemeralKeyPair(profile.keyType); + const before = Math.floor(Date.now() / 1000); + const cert = await signWithVault(profile, token!, pair.publicKey); + + expect(cert).toMatch(/-cert-v01@openssh\.com /); + // The signed cert must parse with the same library used to connect. + const parsed = ssh2Utils.parseKey(cert); + expect(parsed instanceof Error).toBe(false); + + const validBefore = parseCertValidBefore(cert); + expect(validBefore).toBeGreaterThan(before); + }); +}); diff --git a/src/backend/ssh/vault-signer-core.ts b/src/backend/ssh/vault-signer-core.ts new file mode 100644 index 00000000..b1e569e0 --- /dev/null +++ b/src/backend/ssh/vault-signer-core.ts @@ -0,0 +1,265 @@ +// Pure (DB-free) HashiCorp Vault SSH signer logic: ephemeral key generation, +// the Vault OIDC HTTP flow, certificate signing, and certificate parsing. +// +// Kept separate from vault-signer-auth.ts (which adds the encrypted per-user +// certificate cache) so this layer can be unit-tested without the native SQLite +// dependency. + +import crypto from "crypto"; +import ssh2Pkg from "ssh2"; +import { sshLogger } from "../utils/logger.js"; + +const { utils: ssh2Utils } = ssh2Pkg; + +export interface VaultProfileConfig { + id: number; + vaultAddr: string; + vaultNamespace?: string | null; + oidcMount?: string | null; + oidcRole?: string | null; + sshMount?: string | null; + sshRole: string; + validPrincipals?: string | null; + keyType?: string | null; +} + +export interface EphemeralKeyPair { + privateKey: string; + publicKey: string; +} + +export function normalizeAddr(addr: string): string { + return addr.trim().replace(/\/+$/, ""); +} + +export function trimMount( + mount: string | null | undefined, + fallback: string, +): string { + return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, ""); +} + +function vaultHeaders(profile: VaultProfileConfig): Record { + const headers: Record = {}; + if (profile.vaultNamespace?.trim()) { + headers["X-Vault-Namespace"] = profile.vaultNamespace.trim(); + } + return headers; +} + +async function vaultRequest( + url: string, + method: "GET" | "POST" | "PUT", + headers: Record, + body?: Record, +): Promise { + let response: Response; + try { + response = await fetch(url, { + method, + headers: { + ...(body ? { "Content-Type": "application/json" } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + throw new Error( + `Failed to reach Vault at ${url}: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + + const text = await response.text(); + let json: any; + if (text) { + try { + json = JSON.parse(text); + } catch { + // leave undefined for non-JSON bodies + } + } + + if (!response.ok) { + const errs = + json && Array.isArray(json.errors) && json.errors.length + ? json.errors.join("; ") + : text || `HTTP ${response.status}`; + throw new Error(`Vault request failed (${response.status}): ${errs}`); + } + return json; +} + +/** Generate an ephemeral SSH keypair in OpenSSH format. */ +export function generateEphemeralKeyPair( + keyType?: string | null, +): EphemeralKeyPair { + let ssh2Type: "ed25519" | "rsa" | "ecdsa" = "ed25519"; + const options: { bits?: number } = {}; + switch ((keyType || "ssh-ed25519").trim()) { + case "ssh-rsa": + ssh2Type = "rsa"; + options.bits = 4096; + break; + case "ecdsa-sha2-nistp256": + ssh2Type = "ecdsa"; + options.bits = 256; + break; + case "ssh-ed25519": + default: + ssh2Type = "ed25519"; + break; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); + return { privateKey: pair.private, publicKey: pair.public }; +} + +/** + * Start a Vault OIDC login: returns the IdP auth URL plus the state/nonce + * needed to complete the callback. `redirectUri` must be allowed both by the + * Vault role's allowed_redirect_uris and by the IdP. + */ +export async function startVaultOidc( + profile: VaultProfileConfig, + redirectUri: string, +): Promise<{ authUrl: string; state: string; clientNonce: string }> { + const addr = normalizeAddr(profile.vaultAddr); + const mount = trimMount(profile.oidcMount, "oidc"); + const clientNonce = crypto.randomBytes(20).toString("hex"); + + const json = await vaultRequest( + `${addr}/v1/auth/${mount}/oidc/auth_url`, + "POST", + vaultHeaders(profile), + { + role: profile.oidcRole?.trim() || "", + redirect_uri: redirectUri, + client_nonce: clientNonce, + }, + ); + + const authUrl: string | undefined = json?.data?.auth_url; + if (!authUrl) { + throw new Error("Vault did not return an OIDC auth_url"); + } + + // Vault embeds the state it generated in the auth_url; we need it to correlate + // the browser callback back to this login attempt. + let state = ""; + try { + state = new URL(authUrl).searchParams.get("state") || ""; + } catch { + const m = authUrl.match(/[?&]state=([^&]+)/); + state = m ? decodeURIComponent(m[1]) : ""; + } + if (!state) { + throw new Error("Could not determine OIDC state from Vault auth_url"); + } + + return { authUrl, state, clientNonce }; +} + +/** Complete the Vault OIDC callback and return a short-lived Vault token. */ +export async function completeVaultOidc( + profile: VaultProfileConfig, + params: { state: string; code: string; clientNonce: string }, +): Promise { + const addr = normalizeAddr(profile.vaultAddr); + const mount = trimMount(profile.oidcMount, "oidc"); + + const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`); + url.searchParams.set("state", params.state); + url.searchParams.set("code", params.code); + url.searchParams.set("client_nonce", params.clientNonce); + + const json = await vaultRequest(url.toString(), "GET", vaultHeaders(profile)); + const token: string | undefined = json?.auth?.client_token; + if (!token) { + throw new Error("Vault OIDC callback did not return a client token"); + } + return token; +} + +/** Sign an SSH public key with Vault, returning the OpenSSH certificate line. */ +export async function signWithVault( + profile: VaultProfileConfig, + vaultToken: string, + publicKey: string, +): Promise { + const addr = normalizeAddr(profile.vaultAddr); + const mount = trimMount(profile.sshMount, "ssh-client-signer"); + + const headers = { ...vaultHeaders(profile), "X-Vault-Token": vaultToken }; + const body: Record = { + public_key: publicKey.trim(), + cert_type: "user", + }; + if (profile.validPrincipals?.trim()) { + body.valid_principals = profile.validPrincipals.trim(); + } + + const json = await vaultRequest( + `${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`, + "POST", + headers, + body, + ); + const signedKey: string | undefined = json?.data?.signed_key; + if (!signedKey) { + throw new Error("Vault sign response did not include a signed_key"); + } + return signedKey.trim(); +} + +/** + * Parse the "valid before" Unix timestamp out of an OpenSSH certificate. + * Returns 0 if it can't be parsed (caller falls back to a conservative TTL). + */ +export function parseCertValidBefore(signedKey: string): number { + try { + const parts = signedKey.trim().split(/\s+/); + if (parts.length < 2) return 0; + const certType = parts[0]; + const blob = Buffer.from(parts[1], "base64"); + + let pos = 0; + const readString = (): void => { + const len = blob.readUInt32BE(pos); + pos += 4 + len; + }; + const readUint64 = (): number => { + const hi = blob.readUInt32BE(pos); + const lo = blob.readUInt32BE(pos + 4); + pos += 8; + return hi * 0x100000000 + lo; + }; + + readString(); // format id + readString(); // nonce + + let keyFields: number; + if (certType.startsWith("ssh-ed25519")) keyFields = 1; + else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2; + else if (certType.startsWith("ssh-rsa")) keyFields = 2; + else if (certType.startsWith("ssh-dss")) keyFields = 4; + else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2; + else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3; + else return 0; + for (let i = 0; i < keyFields; i++) readString(); + + readUint64(); // serial + pos += 4; // type (uint32) + readString(); // key id + readString(); // valid principals + readUint64(); // valid after + return readUint64(); // valid before + } catch (e) { + sshLogger.warn("Failed to parse Vault-signed certificate expiry", { + operation: "vault_cert_parse", + error: e instanceof Error ? e.message : String(e), + }); + return 0; + } +} diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts index 831c3883..eec42480 100644 --- a/src/backend/ssh/widgets/network-collector.ts +++ b/src/backend/ssh/widgets/network-collector.ts @@ -58,14 +58,36 @@ export async function collectNetworkMetrics(client: Client): Promise<{ } } - for (const [name, data] of ifMap.entries()) { - interfaces.push({ - name, - ip: data.ip, - state: data.state, - rxBytes: null, - txBytes: null, - }); + try { + const procNet = await execCommand(client, "cat /proc/net/dev"); + const rxTxMap = new Map(); + for (const line of procNet.stdout.split("\n").slice(2)) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 10) { + const ifName = parts[0].replace(":", ""); + rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] }); + } + } + for (const [name, data] of ifMap.entries()) { + const rxTx = rxTxMap.get(name); + interfaces.push({ + name, + ip: data.ip, + state: data.state, + rxBytes: rxTx?.rx ?? null, + txBytes: rxTx?.tx ?? null, + }); + } + } catch { + for (const [name, data] of ifMap.entries()) { + interfaces.push({ + name, + ip: data.ip, + state: data.state, + rxBytes: null, + txBytes: null, + }); + } } } catch { // expected diff --git a/src/backend/ssh/widgets/temperature-collector.test.ts b/src/backend/ssh/widgets/temperature-collector.test.ts new file mode 100644 index 00000000..49b0cf95 --- /dev/null +++ b/src/backend/ssh/widgets/temperature-collector.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + parseSensorsOutput, + parseSysfsThermalOutput, +} from "./temperature-collector.js"; + +describe("temperature collectors", () => { + it("parses sysfs thermal zone output", () => { + const result = parseSysfsThermalOutput( + "x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n", + ); + + expect(result).toEqual([ + { label: "x86_pkg_temp", celsius: 51 }, + { label: "acpitz", celsius: 42 }, + ]); + }); + + it("parses lm-sensors temperature lines", () => { + const result = parseSensorsOutput(` +coretemp-isa-0000 +Adapter: ISA adapter +Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C) +Core 0: +48.5°C +fan1: 1200 RPM +`); + + expect(result).toEqual([ + { label: "Package id 0", celsius: 52 }, + { label: "Core 0", celsius: 48.5 }, + ]); + }); +}); diff --git a/src/backend/ssh/widgets/temperature-collector.ts b/src/backend/ssh/widgets/temperature-collector.ts new file mode 100644 index 00000000..1c83811a --- /dev/null +++ b/src/backend/ssh/widgets/temperature-collector.ts @@ -0,0 +1,117 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum } from "./common-utils.js"; + +export interface TemperatureSensor { + label: string; + celsius: number; +} + +export interface TemperatureMetrics { + source: "sysfs" | "sensors" | "none"; + highestCelsius: number | null; + sensors: TemperatureSensor[]; +} + +function normalizeSensor( + label: string, + celsius: number, +): TemperatureSensor | null { + const cleanLabel = label.trim().replace(/\s+/g, " "); + if (!cleanLabel || !Number.isFinite(celsius)) return null; + return { + label: cleanLabel, + celsius: toFixedNum(celsius, 1) ?? celsius, + }; +} + +function summarizeSensors( + source: TemperatureMetrics["source"], + sensors: TemperatureSensor[], +): TemperatureMetrics { + const highest = sensors.reduce( + (max, sensor) => + max === null ? sensor.celsius : Math.max(max, sensor.celsius), + null, + ); + + return { + source: sensors.length > 0 ? source : "none", + highestCelsius: + highest === null ? null : (toFixedNum(highest, 1) ?? highest), + sensors, + }; +} + +export function parseSysfsThermalOutput(output: string): TemperatureSensor[] { + return output + .split("\n") + .map((line) => { + const [label, rawValue] = line.split("\t"); + const value = Number(rawValue); + if (!label || !Number.isFinite(value)) return null; + const celsius = Math.abs(value) >= 1000 ? value / 1000 : value; + return normalizeSensor(label, celsius); + }) + .filter((sensor): sensor is TemperatureSensor => sensor !== null); +} + +export function parseSensorsOutput(output: string): TemperatureSensor[] { + const seen = new Set(); + const sensors: TemperatureSensor[] = []; + + for (const line of output.split("\n")) { + const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i); + if (!match) continue; + + const sensor = normalizeSensor(match[1], Number(match[2])); + if (!sensor) continue; + + const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`; + if (seen.has(key)) continue; + seen.add(key); + sensors.push(sensor); + } + + return sensors; +} + +export async function collectTemperatureMetrics( + client: Client, +): Promise { + try { + const sysfs = await execCommand( + client, + [ + "for zone in /sys/class/thermal/thermal_zone*; do", + '[ -r "$zone/temp" ] || continue;', + 'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";', + 'value="$(cat "$zone/temp" 2>/dev/null || true)";', + '[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";', + "done", + ].join(" "), + 10000, + ); + const sensors = parseSysfsThermalOutput(sysfs.stdout); + if (sensors.length > 0) { + return summarizeSensors("sysfs", sensors); + } + } catch { + // expected on systems without readable thermal zones + } + + try { + const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000); + const sensors = parseSensorsOutput(lmSensors.stdout); + if (sensors.length > 0) { + return summarizeSensors("sensors", sensors); + } + } catch { + // expected when lm-sensors is not installed + } + + return { + source: "none", + highestCelsius: null, + sensors: [], + }; +} diff --git a/src/backend/starter.ts b/src/backend/starter.ts index d2b9e553..a9b57fdc 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -146,7 +146,9 @@ import { await import("./ssh/docker.js"); await import("./ssh/docker-console.js"); await import("./ssh/tmux-monitor.js"); // --- tmux-monitor --- + await import("./serial/serial.js"); await import("./dashboard.js"); + await import("./homepage.js"); // Initialize log level from database settings const { getDb: getDbForSettings } = await import("./database/db/index.js"); diff --git a/src/backend/swagger.ts b/src/backend/swagger.ts index f9e7c932..b1af2f2b 100644 --- a/src/backend/swagger.ts +++ b/src/backend/swagger.ts @@ -42,6 +42,10 @@ const swaggerOptions: SwaggerJSDocOptions = { url: "http://localhost:30007", description: "Docker management server", }, + { + url: "http://localhost:30011", + description: "Serial connection server", + }, ], components: { securitySchemes: { diff --git a/src/backend/utils/alert-trigger.ts b/src/backend/utils/alert-trigger.ts new file mode 100644 index 00000000..c716465b --- /dev/null +++ b/src/backend/utils/alert-trigger.ts @@ -0,0 +1,29 @@ +import { SystemCrypto } from "./system-crypto.js"; +import { sshLogger } from "./logger.js"; + +const METRICS_SERVICE_URL = "http://localhost:30005"; + +export async function triggerLoginAlert( + hostId: number, + userId: string, + sshUser: string, + fromIp: string, +): Promise { + try { + const token = await SystemCrypto.getInstance().getInternalAuthToken(); + await fetch(`${METRICS_SERVICE_URL}/internal/login-alert`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-auth": token, + }, + body: JSON.stringify({ hostId, userId, sshUser, fromIp }), + }); + } catch (err) { + sshLogger.warn("Failed to trigger login alert", { + operation: "login_alert_trigger_error", + hostId, + error: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index c3d5760b..c89e8b2b 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -147,6 +147,31 @@ class AuthManager { return authenticated; } + async setupWebAuthnUserEncryption(userId: string): Promise { + await this.userCrypto.setupWebAuthnUserEncryption(userId); + } + + async authenticateWebAuthnUser( + userId: string, + deviceType?: DeviceType, + ): Promise { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 24 * 60 * 60 * 1000; + + const authenticated = await this.userCrypto.authenticateWebAuthnUser( + userId, + sessionDurationMs, + ); + + if (authenticated) { + await this.performLazyEncryptionMigration(userId); + } + + return authenticated; + } + async convertToOIDCEncryption(userId: string): Promise { await this.userCrypto.convertToOIDCEncryption(userId); } diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts index b3fbf50d..a5352b6a 100644 --- a/src/backend/utils/field-crypto.ts +++ b/src/backend/utils/field-crypto.ts @@ -43,6 +43,8 @@ class FieldCrypto { "publicKey", ]), opkssh_tokens: new Set(["sshCert", "privateKey"]), + termix_identity_ca: new Set(["privateKey"]), + vault_tokens: new Set(["sshCert", "privateKey"]), }; static encryptField( diff --git a/src/backend/utils/guacd-config.ts b/src/backend/utils/guacd-config.ts new file mode 100644 index 00000000..9947ed4d --- /dev/null +++ b/src/backend/utils/guacd-config.ts @@ -0,0 +1,86 @@ +export type GuacdOptions = { + host: string; + port: number; +}; + +const DEFAULT_GUACD_OPTIONS: GuacdOptions = { + host: "localhost", + port: 4822, +}; + +function parsePort(value: string | undefined, fallback: number) { + const port = parseInt(value || "", 10); + return Number.isFinite(port) ? port : fallback; +} + +export function parseGuacdUrl( + value: string, + fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS, +): GuacdOptions { + const raw = value.trim(); + if (!raw) { + return fallback; + } + + if (raw.includes("://")) { + try { + const url = new URL(raw); + return { + host: url.hostname || fallback.host, + port: parsePort(url.port, fallback.port), + }; + } catch { + return fallback; + } + } + + const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (bracketedIpv6) { + return { + host: bracketedIpv6[1], + port: parsePort(bracketedIpv6[2], fallback.port), + }; + } + + const [host, port] = raw.split(":"); + return { + host: host || fallback.host, + port: parsePort(port, fallback.port), + }; +} + +export function getGuacdEnvOptions(): GuacdOptions | null { + if (process.env.GUACD_URL) { + return parseGuacdUrl(process.env.GUACD_URL); + } + + if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) { + return null; + } + + return { + host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host, + port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port), + }; +} + +export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions { + const envOptions = getGuacdEnvOptions(); + if (envOptions) { + return envOptions; + } + + if (dbUrl) { + return parseGuacdUrl(dbUrl); + } + + return DEFAULT_GUACD_OPTIONS; +} + +export function formatGuacdOptions(options: GuacdOptions): string { + return `${options.host}:${options.port}`; +} + +export function getDefaultGuacdUrl(): string { + return formatGuacdOptions(resolveGuacdOptions()); +} diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 19731b88..73b28f61 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -293,5 +293,6 @@ export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899"); export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b"); +export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316"); export const logger = systemLogger; diff --git a/src/backend/utils/notification-sender.ts b/src/backend/utils/notification-sender.ts new file mode 100644 index 00000000..eb83cf70 --- /dev/null +++ b/src/backend/utils/notification-sender.ts @@ -0,0 +1,139 @@ +import { statsLogger } from "./logger.js"; + +export interface AlertPayload { + hostName: string; + hostId: number; + triggerType: string; + value?: number; + threshold?: number; + message: string; + severity: "info" | "warning" | "critical"; + timestamp: string; + ruleId: number; + ruleName: string; +} + +interface WebhookConfig { + url: string; + headers?: Record; + method?: "POST" | "PUT"; +} + +interface NtfyConfig { + url: string; + topic: string; + token?: string; +} + +const NTFY_PRIORITY: Record = { + info: 2, + warning: 3, + critical: 5, +}; + +async function fetchWithRetry( + url: string, + options: RequestInit, +): Promise { + const attempt = async () => { + const res = await fetch(url, options); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + }; + + try { + await attempt(); + } catch (firstErr) { + await new Promise((r) => setTimeout(r, 3000)); + try { + await attempt(); + } catch (secondErr) { + statsLogger.warn("Notification delivery failed after retry", { + operation: "notification_send_failed", + url, + error: + secondErr instanceof Error ? secondErr.message : String(secondErr), + }); + } + } +} + +export async function sendWebhook( + config: WebhookConfig, + payload: AlertPayload, +): Promise { + const { url, headers = {}, method = "POST" } = config; + await fetchWithRetry(url, { + method, + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(payload), + }); +} + +export async function sendNtfy( + config: NtfyConfig, + payload: AlertPayload, +): Promise { + const { url, topic, token } = config; + const ntfyUrl = `${url.replace(/\/$/, "")}/${topic}`; + const headers: Record = { + "Content-Type": "application/json", + Title: `[Termix] ${payload.hostName}: ${payload.ruleName}`, + Priority: String(NTFY_PRIORITY[payload.severity] ?? 3), + Tags: + payload.severity === "critical" + ? "rotating_light" + : payload.severity === "warning" + ? "warning" + : "information_source", + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + await fetchWithRetry(ntfyUrl, { + method: "POST", + headers, + body: payload.message, + }); +} + +export interface NotificationChannel { + id: number; + type: string; + config: string; + enabled: boolean; +} + +export async function sendNotification( + channel: NotificationChannel, + payload: AlertPayload, +): Promise { + if (!channel.enabled) return; + + let parsedConfig: Record; + try { + parsedConfig = JSON.parse(channel.config) as Record; + } catch { + statsLogger.warn("Failed to parse notification channel config", { + operation: "notification_config_parse_error", + channelId: channel.id, + }); + return; + } + + try { + if (channel.type === "webhook") { + await sendWebhook(parsedConfig as unknown as WebhookConfig, payload); + } else if (channel.type === "ntfy") { + await sendNtfy(parsedConfig as unknown as NtfyConfig, payload); + } + } catch (err) { + statsLogger.warn("Notification send error", { + operation: "notification_send_error", + channelId: channel.id, + type: channel.type, + error: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/src/backend/utils/request-origin.test.ts b/src/backend/utils/request-origin.test.ts new file mode 100644 index 00000000..d569f6e9 --- /dev/null +++ b/src/backend/utils/request-origin.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + getRequestBasePath, + getRequestBaseUrl, + getRequestBaseUrlWithForceHTTPS, + normalizeBasePath, +} from "./request-origin.js"; + +function request(headers: Record) { + return { + headers, + socket: {}, + } as Parameters[0]; +} + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +describe("normalizeBasePath", () => { + it("normalizes empty and root paths", () => { + expect(normalizeBasePath("")).toBe(""); + expect(normalizeBasePath("/")).toBe(""); + expect(normalizeBasePath(" / ")).toBe(""); + }); + + it("normalizes configured base paths", () => { + expect(normalizeBasePath("termix")).toBe("/termix"); + expect(normalizeBasePath("/termix/")).toBe("/termix"); + expect(normalizeBasePath("/termix, /other")).toBe("/termix"); + }); + + it("accepts forwarded prefix values that include a URL", () => { + expect(normalizeBasePath("https://example.com/termix/")).toBe("/termix"); + }); +}); + +describe("getRequestBasePath", () => { + const previousBasePath = process.env.BASE_PATH; + const previousViteBasePath = process.env.VITE_BASE_PATH; + const previousForceHttps = process.env.OIDC_FORCE_HTTPS; + + afterEach(() => { + restoreEnv("BASE_PATH", previousBasePath); + restoreEnv("VITE_BASE_PATH", previousViteBasePath); + restoreEnv("OIDC_FORCE_HTTPS", previousForceHttps); + }); + + it("uses BASE_PATH before forwarded headers", () => { + process.env.BASE_PATH = "/admin/"; + process.env.VITE_BASE_PATH = ""; + + expect( + getRequestBasePath(request({ "x-forwarded-prefix": "/termix" })), + ).toBe("/admin"); + }); + + it("falls back to VITE_BASE_PATH for deployments that already set it", () => { + process.env.BASE_PATH = ""; + process.env.VITE_BASE_PATH = "/termix/"; + + expect(getRequestBasePath(request({}))).toBe("/termix"); + }); + + it("uses X-Forwarded-Prefix when no env base path is set", () => { + process.env.BASE_PATH = ""; + process.env.VITE_BASE_PATH = ""; + + expect( + getRequestBasePath(request({ "x-forwarded-prefix": "/termix/" })), + ).toBe("/termix"); + }); + + it("builds a public base URL with forwarded origin and prefix", () => { + process.env.BASE_PATH = ""; + process.env.VITE_BASE_PATH = ""; + + expect( + getRequestBaseUrl( + request({ + "x-forwarded-proto": "https", + "x-forwarded-host": "example.com", + "x-forwarded-prefix": "/termix", + }), + ), + ).toBe("https://example.com/termix"); + }); + + it("applies OIDC_FORCE_HTTPS to public base URLs", () => { + process.env.BASE_PATH = ""; + process.env.VITE_BASE_PATH = ""; + process.env.OIDC_FORCE_HTTPS = "true"; + + expect( + getRequestBaseUrlWithForceHTTPS( + request({ + "x-forwarded-proto": "http", + "x-forwarded-host": "example.com", + "x-forwarded-prefix": "/termix", + }), + ), + ).toBe("https://example.com/termix"); + }); +}); diff --git a/src/backend/utils/request-origin.ts b/src/backend/utils/request-origin.ts index ea0a530f..1f91d1cf 100644 --- a/src/backend/utils/request-origin.ts +++ b/src/backend/utils/request-origin.ts @@ -1,6 +1,31 @@ import type { Request } from "express"; import type { IncomingMessage } from "http"; +function firstHeaderValue(value: string | string[] | undefined): string { + if (!value) return ""; + const raw = Array.isArray(value) ? value[0] : value; + return raw.split(",")[0].trim(); +} + +export function normalizeBasePath(value: unknown): string { + if (typeof value !== "string") return ""; + let basePath = value.split(",")[0].trim(); + if (!basePath) return ""; + + try { + if (/^https?:\/\//i.test(basePath)) { + basePath = new URL(basePath).pathname; + } + } catch { + return ""; + } + + basePath = basePath.split("?")[0].split("#")[0].trim(); + if (!basePath || basePath === "/") return ""; + if (!basePath.startsWith("/")) basePath = `/${basePath}`; + return basePath.replace(/\/+$/, ""); +} + export function getRequestOrigin(req: Request | IncomingMessage): string { let protocol: string; const protoHeader = req.headers["x-forwarded-proto"]; @@ -63,3 +88,26 @@ export function getRequestOriginWithForceHTTPS( } return getRequestOrigin(req); } + +export function getRequestBasePath(req: Request | IncomingMessage): string { + const envBasePath = normalizeBasePath( + process.env.BASE_PATH || process.env.VITE_BASE_PATH, + ); + if (envBasePath) return envBasePath; + + return ( + normalizeBasePath(firstHeaderValue(req.headers["x-forwarded-prefix"])) || + normalizeBasePath(firstHeaderValue(req.headers["x-script-name"])) || + normalizeBasePath(firstHeaderValue(req.headers["x-original-prefix"])) + ); +} + +export function getRequestBaseUrl(req: Request | IncomingMessage): string { + return `${getRequestOrigin(req)}${getRequestBasePath(req)}`; +} + +export function getRequestBaseUrlWithForceHTTPS( + req: Request | IncomingMessage, +): string { + return `${getRequestOriginWithForceHTTPS(req)}${getRequestBasePath(req)}`; +} diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts index dad95bc1..a000ecda 100644 --- a/src/backend/utils/shared-credential-manager.ts +++ b/src/backend/utils/shared-credential-manager.ts @@ -39,6 +39,19 @@ class SharedCredentialManager { ownerId: string, ): Promise { try { + const existing = await db + .select({ id: sharedCredentials.id }) + .from(sharedCredentials) + .where( + and( + eq(sharedCredentials.hostAccessId, hostAccessId), + eq(sharedCredentials.targetUserId, targetUserId), + ), + ) + .limit(1); + + if (existing.length > 0) return; + const ownerDEK = DataCrypto.getUserDataKey(ownerId); if (ownerDEK) { @@ -158,6 +171,83 @@ class SharedCredentialManager { } } + async createSharedCredentialsForRoleMember( + roleId: number, + targetUserId: string, + ): Promise { + try { + const hostsSharedWithRole = await db + .select() + .from(hostAccess) + .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) + .where(eq(hostAccess.roleId, roleId)); + + for (const { host_access, ssh_data } of hostsSharedWithRole) { + const activeCredentialId = + ssh_data.credentialId ?? + ssh_data.rdpCredentialId ?? + ssh_data.vncCredentialId ?? + ssh_data.telnetCredentialId; + + if (!activeCredentialId) continue; + + try { + await this.createSharedCredentialForUser( + host_access.id, + activeCredentialId, + targetUserId, + ssh_data.userId, + ); + } catch (error) { + databaseLogger.error( + "Failed to create shared credential for role member", + error, + { + operation: "create_shared_credentials_role_member", + roleId, + targetUserId, + hostId: ssh_data.id, + }, + ); + } + } + } catch (error) { + databaseLogger.error( + "Failed to create shared credentials for role member", + error, + { + operation: "create_shared_credentials_role_member", + roleId, + targetUserId, + }, + ); + throw error; + } + } + + async createSharedCredentialsForUserRoles(userId: string): Promise { + try { + const roleRows = await db + .select({ roleId: userRoles.roleId }) + .from(userRoles) + .where(eq(userRoles.userId, userId)); + + for (const { roleId } of roleRows) { + await this.createSharedCredentialsForRoleMember(roleId, userId); + } + } catch (error) { + databaseLogger.error( + "Failed to create shared credentials for user roles", + error, + { + operation: "create_shared_credentials_user_roles", + userId, + }, + ); + throw error; + } + } + async getSharedCredentialForUser( hostId: number, userId: string, diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index ab435503..ddf8df75 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -7,6 +7,7 @@ type TableName = | "users" | "ssh_data" | "ssh_credentials" + | "termix_identity_ca" | "recent_activity" | "socks5_proxy_presets"; diff --git a/src/backend/utils/ssh-key-utils.test.ts b/src/backend/utils/ssh-key-utils.test.ts index a0418c96..52d7669a 100644 --- a/src/backend/utils/ssh-key-utils.test.ts +++ b/src/backend/utils/ssh-key-utils.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; import { parseSSHKey, parsePublicKey, + preparePrivateKeyForSSH2, getFriendlyKeyTypeName, validateKeyPair, } from "./ssh-key-utils.js"; @@ -20,6 +22,11 @@ AAAEDLo85Twyg0v6V1zsJaeRaxq9KPQXkqGY0HiJtVMzCXEFH+Ektft4yKdLjAkx8baBZK const ED25519_PUBLIC = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFH+Ektft4yKdLjAkx8baBZK21SK5Iu+oPBVhPnfHSp7 test@termix.test"; +const PPK_RSA_PRIVATE = readFileSync( + "node_modules/ssh2/test/fixtures/keyParser/ppk_rsa", + "utf8", +); + describe("parsePublicKey", () => { it("detects ssh-ed25519 public keys", () => { const info = parsePublicKey(ED25519_PUBLIC); @@ -68,6 +75,26 @@ describe("parseSSHKey", () => { expect(info.success).toBe(false); expect(info.keyType).toBe("unknown"); }); + + it("accepts PuTTY PPK v2 private keys supported by ssh2", () => { + const info = parseSSHKey(PPK_RSA_PRIVATE); + expect(info.success).toBe(true); + expect(info.keyType).toBe("ssh-rsa"); + expect(info.publicKey).toContain("ssh-rsa"); + }); + + it("prepares PuTTY PPK v2 private keys for ssh2 connections", () => { + const prepared = preparePrivateKeyForSSH2(PPK_RSA_PRIVATE); + expect(prepared.toString("utf8")).toContain("PuTTY-User-Key-File-2"); + }); + + it("reports unsupported PuTTY PPK versions clearly", () => { + const info = parseSSHKey( + "PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\n", + ); + expect(info.success).toBe(false); + expect(info.error).toMatch(/Unsupported PuTTY PPK v3/); + }); }); describe("getFriendlyKeyTypeName", () => { diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 3addc2d8..2234d78a 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -225,20 +225,53 @@ export interface KeyPairValidationResult { error?: string; } +const PUTTY_PRIVATE_KEY_RE = /^PuTTY-User-Key-File-(\d+):\s*(.+)$/m; + +export function normalizePrivateKeyText(privateKeyData: string): string { + return privateKeyData.trim().replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function getUnsupportedPrivateKeyError(privateKeyData: string): string { + const puttyMatch = PUTTY_PRIVATE_KEY_RE.exec(privateKeyData.trim()); + if (puttyMatch) { + const [, version, type] = puttyMatch; + return `Unsupported PuTTY PPK v${version} private key format (${type}). Convert the key to OpenSSH format with PuTTYgen, or use a PuTTY PPK v2 RSA/DSA key.`; + } + + return "Unsupported private key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key."; +} + +export function preparePrivateKeyForSSH2( + privateKeyData: string, + passphrase?: string, +): Buffer { + const cleanKey = normalizePrivateKeyText(privateKeyData); + const keyInfo = parseSSHKey(cleanKey, passphrase); + + if (!keyInfo.success) { + throw new Error(keyInfo.error || getUnsupportedPrivateKeyError(cleanKey)); + } + + return Buffer.from(cleanKey, "utf8"); +} + export function parseSSHKey( privateKeyData: string, passphrase?: string, ): KeyInfo { try { + const cleanKey = normalizePrivateKeyText(privateKeyData); let keyType = "unknown"; let publicKey = ""; let useSSH2 = false; if (ssh2Utils && typeof ssh2Utils.parseKey === "function") { try { - const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase); + const parsedKey = ssh2Utils.parseKey(cleanKey, passphrase); - if (!(parsedKey instanceof Error)) { + if (parsedKey instanceof Error) { + throw parsedKey; + } else { if (parsedKey.type) { keyType = parsedKey.type; } @@ -273,23 +306,28 @@ export function parseSSHKey( } if (!useSSH2) { - keyType = detectKeyTypeFromContent(privateKeyData); + keyType = detectKeyTypeFromContent(cleanKey); publicKey = ""; } return { - privateKey: privateKeyData, + privateKey: cleanKey, publicKey, keyType, success: keyType !== "unknown", + error: + keyType === "unknown" + ? getUnsupportedPrivateKeyError(cleanKey) + : undefined, }; } catch (error) { try { - const fallbackKeyType = detectKeyTypeFromContent(privateKeyData); + const cleanKey = normalizePrivateKeyText(privateKeyData); + const fallbackKeyType = detectKeyTypeFromContent(cleanKey); if (fallbackKeyType !== "unknown") { return { - privateKey: privateKeyData, + privateKey: cleanKey, publicKey: "", keyType: fallbackKeyType, success: true, @@ -299,13 +337,18 @@ export function parseSSHKey( // expected - fallback key type detection may fail } + const parserError = error instanceof Error ? error.message : ""; + const isPuttyKey = PUTTY_PRIVATE_KEY_RE.test(privateKeyData.trim()); + return { privateKey: privateKeyData, publicKey: "", keyType: "unknown", success: false, error: - error instanceof Error ? error.message : "Unknown error parsing key", + isPuttyKey && /unsupported|parse|format/i.test(parserError) + ? getUnsupportedPrivateKeyError(privateKeyData) + : parserError || getUnsupportedPrivateKeyError(privateKeyData), }; } } diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index f8fb1b32..5ff3ee22 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -245,6 +245,62 @@ class UserCrypto { } } + async setupWebAuthnUserEncryption(userId: string): Promise { + const existingDEK = this.getUserDataKey(userId); + + if (!existingDEK) { + throw new Error( + "Cannot enable WebAuthn encryption - user session not active. Please log in first.", + ); + } + + const systemKey = this.deriveWebAuthnSystemKey(userId); + const encryptedDEK = this.encryptDEK(existingDEK, systemKey); + systemKey.fill(0); + + await this.storeWebAuthnEncryptedDEK(userId, encryptedDEK); + } + + async authenticateWebAuthnUser( + userId: string, + sessionDurationMs: number, + ): Promise { + try { + const encryptedDEK = await this.getWebAuthnEncryptedDEK(userId); + if (!encryptedDEK) { + return false; + } + + const systemKey = this.deriveWebAuthnSystemKey(userId); + const DEK = this.decryptDEK(encryptedDEK, systemKey); + systemKey.fill(0); + + if (!DEK || DEK.length === 0) { + return false; + } + + const oldSession = this.userSessions.get(userId); + if (oldSession) { + oldSession.dataKey.fill(0); + } + + this.userSessions.set(userId, { + dataKey: Buffer.from(DEK), + expiresAt: Date.now() + sessionDurationMs, + }); + + DEK.fill(0); + return true; + } catch (error) { + databaseLogger.warn("WebAuthn authentication failed", { + operation: "webauthn_auth_failed", + userId, + error: error instanceof Error ? error.message : "Unknown", + }); + return false; + } + } + getUserDataKey(userId: string): Buffer | null { const session = this.userSessions.get(userId); if (!session) { @@ -513,6 +569,21 @@ class UserCrypto { ); } + private deriveWebAuthnSystemKey(userId: string): Buffer { + const systemSecret = + process.env.WEBAUTHN_SYSTEM_SECRET || + process.env.OIDC_SYSTEM_SECRET || + "termix-webauthn-system-secret-default"; + const salt = Buffer.from(`webauthn:${userId}`, "utf8"); + return crypto.pbkdf2Sync( + systemSecret, + salt, + 100000, + UserCrypto.KEK_LENGTH, + "sha256", + ); + } + private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv("aes-256-gcm", kek, iv); @@ -640,6 +711,48 @@ class UserCrypto { return null; } } + + private async storeWebAuthnEncryptedDEK( + userId: string, + encryptedDEK: EncryptedDEK, + ): Promise { + const key = `user_encrypted_dek_webauthn_${userId}`; + const value = JSON.stringify(encryptedDEK); + + const existing = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (existing.length > 0) { + await getDb() + .update(settings) + .set({ value }) + .where(eq(settings.key, key)); + } else { + await getDb().insert(settings).values({ key, value }); + } + } + + private async getWebAuthnEncryptedDEK( + userId: string, + ): Promise { + try { + const key = `user_encrypted_dek_webauthn_${userId}`; + const result = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (result.length === 0) { + return null; + } + + return JSON.parse(result[0].value); + } catch { + return null; + } + } } export { UserCrypto, type KEKSalt, type EncryptedDEK }; diff --git a/src/main.tsx b/src/main.tsx index 7258d9c3..00ed6260 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -52,6 +52,12 @@ const TmuxMonitorApp = lazy(() => })), ); +const HomepageApp = lazy(() => + import("@/features/homepage/HomepageApp").then((m) => ({ + default: m.default, + })), +); + const ElectronVersionCheck = lazy(() => import("@/user/ElectronVersionCheck").then((module) => ({ default: module.ElectronVersionCheck, @@ -106,11 +112,61 @@ function FullscreenApp() { case "tmux-monitor": // --- tmux-monitor --- case "tmux_monitor": // tab type spelling, so copied links also resolve return ; + case "homepage": + return ; default: return null; } } +function FullscreenAppGate() { + const { t } = useTranslation(); + const [ready, setReady] = useState(false); + const [authFailed, setAuthFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + + appReadyPromise + .then(() => getUserInfo()) + .then(async () => { + if (isElectron()) { + try { + const token = await getCurrentToken(); + if (token) localStorage.setItem("jwt", token); + } catch { + // WebSocket connections can still fall back to cookie auth. + } + } + if (!cancelled) setReady(true); + }) + .catch(() => { + if (!cancelled) setAuthFailed(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + if (authFailed) { + return ; + } + + if (!ready) { + return ( +
+
+
+

{t("common.loading")}

+
+
+ ); + } + + return ; +} + function App() { const stored = getStoredAuth(); const [phase, setPhase] = useState( @@ -118,6 +174,10 @@ function App() { ); const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const timerRef = useRef | null>(null); + // Track whether fading-in came from a fresh login (vs. session verification on page load). + // When session-verified, Auth must not mount during the transition — it would trigger + // silent OIDC redirect and cause an infinite refresh loop. + const fadingInFromLoginRef = useRef(false); useEffect(() => { const savedAccent = localStorage.getItem("termix-accent"); @@ -139,14 +199,16 @@ function App() { if (phase !== "verifying") return; appReadyPromise .then(() => getUserInfo()) - .then(() => { + .then(async () => { if (isElectron()) { - getCurrentToken() - .then((token) => { - if (token) localStorage.setItem("jwt", token); - }) - .catch(() => {}); + try { + const token = await getCurrentToken(); + if (token) localStorage.setItem("jwt", token); + } catch { + // Non-fatal: WebSocket connections will fall back to cookie auth + } } + fadingInFromLoginRef.current = false; setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); }) @@ -158,6 +220,7 @@ function App() { function handleLogin(u: string) { setAuthUsername(u); + fadingInFromLoginRef.current = true; setPhase("fading-in"); timerRef.current = setTimeout(() => setPhase("idle-app"), 450); if (isElectron()) { @@ -182,7 +245,9 @@ function App() { const showApp = phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; const showAuth = - phase === "idle-auth" || phase === "fading-in" || phase === "fading-out"; + phase === "idle-auth" || + (phase === "fading-in" && fadingInFromLoginRef.current) || + phase === "fading-out"; const appOpacity = phase === "idle-app" ? 1 : 0; const authOpacity = phase === "idle-auth" ? 1 : 0; @@ -259,7 +324,7 @@ function RootApp() { if (isFullscreen) { return ( - + ); } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 23785dfd..61a38183 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -29,6 +29,8 @@ interface DialogResult { export interface ElectronAPI { getAppVersion: () => Promise; getPlatform: () => Promise; + getSetting?: (key: string) => Promise; + setSetting?: (key: string, value: string) => Promise; getServerConfig: () => Promise; saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>; @@ -77,6 +79,32 @@ export interface ElectronAPI { showSaveDialog: (options: DialogOptions) => Promise; showOpenDialog: (options: DialogOptions) => Promise; + openExternalEditor: (fileData: { + fileName: string; + content: string; + encoding?: "utf8" | "base64"; + editorPath?: string | null; + }) => Promise<{ + success: boolean; + editId?: string; + path?: string; + error?: string; + }>; + + closeExternalEditor: (editId: string) => Promise<{ + success: boolean; + error?: string; + }>; + + onExternalEditorSaved?: ( + callback: (payload: { + editId: string; + content: string; + encoding: "utf8"; + path: string; + }) => void, + ) => () => void; + onUpdateAvailable: (callback: () => void) => void; onUpdateDownloaded: (callback: () => void) => void; diff --git a/src/types/homepage-types.ts b/src/types/homepage-types.ts new file mode 100644 index 00000000..b6611876 --- /dev/null +++ b/src/types/homepage-types.ts @@ -0,0 +1,417 @@ +export const GRID_SIZE = 30; +export const MIN_ZOOM = 0.4; +export const MAX_ZOOM = 2.0; +export const ZOOM_STEP = 0.1; + +export type WidgetTypeId = + | "service_link" + | "folder" + | "clock" + | "notes" + | "host_status" + | "bookmark_list" + | "weather" + | "iframe_embed" + | "rss_feed" + | "metrics_chart" + | "host_grid" + | "alert_feed" + | "ping_status" + | "recent_activity" + | "termix_uptime" + | "system_overview" + | "ssh_terminal" + | "quick_connect" + | "file_manager_widget" + | "docker_widget" + | "tunnel_widget" + | "calendar" + | "countdown" + | "search_bar" + | "text_banner" + | "image_widget" + | "markdown_notes" + | "custom_api" + | "service_grid" + | "dashboard_links" + | "search_links" + | "link_tree"; + +export interface HomepageItemRow { + id: number; + userId: string; + typeId: WidgetTypeId; + title: string | null; + config: string; + createdAt: string; + updatedAt: string; +} + +export interface HomepageLayoutEntry { + itemId: number; + x: number; + y: number; + w: number; + h: number; + zOrder?: number; +} + +export interface HomepageLayoutData { + entries: HomepageLayoutEntry[]; + pan: { x: number; y: number }; + zoom: number; +} + +export interface HomepageLayoutRow { + id: number; + userId: string; + layout: HomepageLayoutData; + updatedAt: string; +} + +export interface CanvasWidget { + id: number; + typeId: WidgetTypeId; + title: string | null; + config: Record; + x: number; + y: number; + w: number; + h: number; + zOrder: number; +} + +// ---- Per-widget config shapes ---- + +export interface ServiceLinkConfig { + url: string; + description?: string; + accentColor?: string; + imageUrl?: string; + showImage?: boolean; +} + +export interface FolderConfig { + color?: string; + isExpanded: boolean; +} + +export interface ClockConfig { + timezone?: string; + showSeconds: boolean; + format: "12h" | "24h"; +} + +export interface NotesConfig { + content: string; + backgroundColor?: string; +} + +export type HostMetricKey = + | "cpu" + | "memory" + | "disk" + | "uptime" + | "network" + | "system" + | "processes"; + +export interface HostStatusConfig { + hostId: number; + /** @deprecated use shownMetrics instead */ + showMetrics?: boolean; + /** @deprecated use shownMetrics instead */ + showDisk?: boolean; + shownMetrics: HostMetricKey[]; + showSparkline?: boolean; +} + +export interface BookmarkLink { + label: string; + url: string; +} + +export interface BookmarkListConfig { + links: BookmarkLink[]; +} + +export interface WeatherConfig { + location: string; + unit: "C" | "F"; + showForecast: boolean; +} + +export interface IframeConfig { + url: string; + scrolling: boolean; +} + +export interface RssFeedConfig { + feedUrl: string; + maxItems: number; + showDescription: boolean; +} + +// ---- New widget configs ---- + +export type MetricsChartMetric = + | "cpu" + | "memory" + | "disk" + | "net_rx" + | "net_tx"; +export type MetricsChartRange = "15m" | "1h" | "6h" | "24h"; + +export interface MetricsChartConfig { + hostId: number; + metric: MetricsChartMetric; + range: MetricsChartRange; + showCurrentValue: boolean; +} + +export interface HostGridConfig { + hostIds: number[]; + showIp: boolean; + columns: 2 | 3 | 4; +} + +export interface AlertFeedConfig { + maxItems: number; + showAcknowledged: boolean; +} + +export interface PingUrl { + label: string; + url: string; +} + +export interface PingStatusConfig { + urls: PingUrl[]; + refreshInterval: number; + showLatency: boolean; +} + +export type ActivityType = + | "terminal" + | "file_manager" + | "docker" + | "tunnel" + | "rdp" + | "vnc" + | "telnet"; + +export interface RecentActivityConfig { + maxItems: number; + filterTypes: ActivityType[]; + showTimestamp: boolean; +} + +export interface TermixUptimeConfig { + showDetailed: boolean; +} + +export interface SystemOverviewConfig { + showVersion: boolean; + showDbHealth: boolean; + showUptime: boolean; +} + +export interface FileManagerWidgetConfig { + hostId: number; +} + +export interface DockerWidgetConfig { + hostId: number; +} + +export interface TunnelWidgetConfig { + hostId: number; +} + +export interface SshTerminalConfig { + hostId: number; + autoConnect: boolean; +} + +export type QuickConnectType = + | "terminal" + | "files" + | "docker" + | "tunnel" + | "host-metrics" + | "rdp" + | "vnc" + | "telnet"; + +export interface QuickConnectConfig { + hostIds: number[]; + connectionTypes: QuickConnectType[]; + showStatus: boolean; + layout: "grid" | "list"; +} + +export interface CalendarConfig { + timezone?: string; + startOnMonday: boolean; +} + +export interface CountdownConfig { + targetDate: string; + label: string; + showDays: boolean; + showHours: boolean; +} + +export type SearchEngine = "google" | "duckduckgo" | "bing" | "custom"; + +export interface SearchBarConfig { + engine: SearchEngine; + customUrl?: string; + placeholder?: string; + openInNewTab: boolean; +} + +export type TextBannerFontSize = "sm" | "md" | "lg" | "xl"; +export type TextBannerAlign = "left" | "center" | "right"; +export type TextBannerWeight = "normal" | "semibold" | "bold"; + +export interface TextBannerConfig { + text: string; + fontSize: TextBannerFontSize; + textAlign: TextBannerAlign; + fontWeight: TextBannerWeight; + backgroundColor?: string; +} + +export interface ImageWidgetConfig { + imageUrl: string; + fit: "contain" | "cover" | "fill"; + alt?: string; + linkUrl?: string; +} + +export interface MarkdownNotesConfig { + content: string; + backgroundColor?: string; + renderMarkdown: boolean; +} + +export type CustomApiDisplayMode = "value" | "json" | "table"; + +export interface CustomApiConfig { + url: string; + displayField?: string; + label?: string; + unit?: string; + refreshInterval: number; + displayMode: CustomApiDisplayMode; + jsonPath?: string; +} + +export interface ServiceGridItem { + label: string; + url: string; + imageUrl?: string; + accentColor?: string; +} + +export interface ServiceGridConfig { + services: ServiceGridItem[]; + columns: 2 | 3 | 4; + showLabels: boolean; + iconSize: "sm" | "md" | "lg"; +} + +export interface DashboardLinksConfig { + showIcons: boolean; + columns: 1 | 2 | 3; + maxItems?: number; +} + +export interface SearchLinkShortcut { + label: string; + queryTemplate: string; + icon?: string; + accentColor?: string; +} + +export interface SearchLinksConfig { + shortcuts: SearchLinkShortcut[]; +} + +export interface LinkTreeLink { + label: string; + url: string; + description?: string; +} + +export interface LinkTreeSection { + heading: string; + links: LinkTreeLink[]; +} + +export interface LinkTreeConfig { + sections: LinkTreeSection[]; + compact: boolean; +} + +// ---- Widget registry types ---- + +export interface WidgetComponentProps> { + widget: CanvasWidget; + config: C; + isReadOnly?: boolean; + onConfigUpdate?: (config: Record) => void; +} + +export interface WidgetEditFormProps> { + config: C; + onChange: (config: C) => void; +} + +export interface WidgetTypeDefinition> { + id: WidgetTypeId; + name: string; + description: string; + category: "links" | "info" | "system" | "monitoring"; + icon: React.ReactNode; + defaultConfig: C; + defaultSize: { w: number; h: number }; + minSize: { w: number; h: number }; + component: React.ComponentType>; + editFormComponent?: React.ComponentType>; +} + +export interface DragState { + widgetId: number; + startMouseX: number; + startMouseY: number; + startWidgetX: number; + startWidgetY: number; +} + +export interface ResizeState { + widgetId: number; + startMouseX: number; + startMouseY: number; + startW: number; + startH: number; +} + +export interface ContextMenuAnchor { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +} + +export interface ContextMenuState { + visible: boolean; + screenX: number; + screenY: number; + canvasX: number; + canvasY: number; + anchorRect?: ContextMenuAnchor; +} diff --git a/src/types/host-metrics.ts b/src/types/host-metrics.ts index d604387e..25cf9f90 100644 --- a/src/types/host-metrics.ts +++ b/src/types/host-metrics.ts @@ -21,7 +21,9 @@ export type HostMetricManagerId = | "health_check" | "disk_breakdown" | "systemd_timers" - | "top_memory"; + | "top_memory" + | "wireguard_manager" + | "tailscale_manager"; export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId; @@ -51,6 +53,7 @@ export const METRIC_CARD_IDS: HostMetricCardId[] = [ "processes", "ports", "firewall", + "temperature", ]; export const MANAGER_CARD_IDS: HostMetricManagerId[] = [ @@ -66,6 +69,8 @@ export const MANAGER_CARD_IDS: HostMetricManagerId[] = [ "disk_breakdown", "systemd_timers", "top_memory", + "wireguard_manager", + "tailscale_manager", ]; export function isMetricCardId(id: string): id is HostMetricCardId { @@ -96,6 +101,7 @@ const DEFAULT_COLSPAN: Partial> = uptime: 1, system: 1, network: 1, + temperature: 1, processes: 2, ports: 2, firewall: 2, diff --git a/src/types/index.ts b/src/types/index.ts index af1d3ff1..67f6a2cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -102,7 +102,14 @@ export interface Host { folder: string; tags: string[]; pin: boolean; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "agent"; useWarpgate?: boolean; password?: string; key?: string; @@ -161,6 +168,7 @@ export interface Host { security?: string; ignoreCert?: boolean; guacamoleConfig?: string | Record; + dockerConfig?: Record | null; enableSsh?: boolean; enableRdp?: boolean; @@ -221,7 +229,14 @@ export interface HostData { folder?: string; tags?: string[]; pin?: boolean; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "agent"; useWarpgate?: boolean; password?: string; key?: File | null; @@ -530,6 +545,7 @@ export interface FileItem { sshSessionId?: string; size?: number; modified?: string; + modifiedTimestamp?: number; permissions?: string; owner?: string; group?: string; @@ -622,6 +638,30 @@ export interface TerminalConfig { allowLegacyAlgorithms?: boolean; linkClickBehavior?: "confirm" | "direct"; useSSHTitle?: boolean; + agentSocketPath?: string; + customThemeColors?: { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; + }; } // ============================================================================ @@ -656,6 +696,7 @@ export interface TabContextTab { export interface TerminalRefHandle { disconnect?: () => void; reconnect?: () => void; + isConnected?: () => boolean; fit?: () => void; sendInput?: (data: string) => void; notifyResize?: () => void; @@ -1045,6 +1086,7 @@ export interface DockerLogOptions { export interface DockerValidation { available: boolean; version?: string; + runtime?: "docker" | "podman"; error?: string; code?: string; } diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index 4ea351dd..33b485c3 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -8,7 +8,8 @@ export type WidgetType = | "system" | "login_stats" | "ports" - | "firewall"; + | "firewall" + | "temperature"; export interface ListeningPort { protocol: "tcp" | "udp"; @@ -49,6 +50,17 @@ export interface FirewallMetrics { chains: FirewallChain[]; } +export interface TemperatureSensor { + label: string; + celsius: number; +} + +export interface TemperatureMetrics { + source: "sysfs" | "sensors" | "none"; + highestCelsius: number | null; + sensors: TemperatureSensor[]; +} + export interface StatsConfig { enabledWidgets: WidgetType[]; statusCheckEnabled: boolean; @@ -72,6 +84,7 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = { "processes", "ports", "firewall", + "temperature", ], statusCheckEnabled: true, statusCheckInterval: 30, diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index e41813d2..4d9e14f4 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -10,9 +10,18 @@ export type Host = { ram: number | null; lastAccess: string; tags?: string[]; - authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale"; + authType: + | "password" + | "key" + | "credential" + | "none" + | "opkssh" + | "tailscale" + | "vault" + | "agent"; useWarpgate?: boolean; credentialId?: string; + vaultProfileId?: string; overrideCredentialUsername?: boolean; password?: string; hasPassword?: boolean; @@ -56,6 +65,7 @@ export type Host = { environmentVariables: { key: string; value: string }[]; startupSnippetId?: number | null; linkClickBehavior?: "confirm" | "direct"; + agentSocketPath?: string; }; useSocks5?: boolean; @@ -95,6 +105,9 @@ export type Host = { defaultPath?: string; enableDocker: boolean; + dockerConfig?: { + runtime?: "docker" | "podman"; + } | null; enableProxmox: boolean; enableTmuxMonitor: boolean; proxmoxConfig?: { @@ -150,6 +163,7 @@ export type Credential = { username: string; type: "password" | "key"; value?: string; + password?: string; publicKey?: string; passphrase?: string; description?: string; @@ -157,6 +171,26 @@ export type Credential = { tags?: string[]; }; +// HashiCorp Vault SSH signer profile — shareable connection settings only +// (no secrets). Users authenticate to Vault via OIDC at connect time. +export type VaultProfile = { + id: string; + name: string; + description?: string; + folder?: string; + tags?: string[]; + vaultAddr: string; + vaultNamespace?: string; + oidcMount?: string; + oidcRole?: string; + sshMount?: string; + sshRole: string; + validPrincipals?: string; + keyType?: string; + shared: boolean; + owned: boolean; +}; + export type HostFolder = { name: string; children: (Host | HostFolder)[]; @@ -179,7 +213,17 @@ export type TabType = | "docker" | "tunnel" | "network_graph" - | "tmux_monitor"; // --- tmux-monitor --- + | "tmux_monitor" // --- tmux-monitor --- + | "serial" + | "homepage"; + +export type SerialConfig = { + path: string; + baudRate: number; + dataBits: 5 | 6 | 7 | 8; + stopBits: 1 | 2; + parity: "none" | "even" | "odd"; +}; export type TunnelStatusValue = | "CONNECTED" @@ -213,7 +257,10 @@ export type Tab = { openedAt: number; restoredSessionId?: string | null; initialFilePath?: string; + serialConfig?: SerialConfig; terminalRef?: import("react").RefObject<{ + disconnect?: () => void; + isConnected?: () => boolean; sendInput?: (data: string) => void; reconnect?: () => void; fit?: () => void; @@ -247,7 +294,8 @@ export type DashboardCardId = | "host_status" | "recent_activity" | "network_graph" - | "service_links"; + | "service_links" + | "homepage_preview"; export type DashboardCardConfig = { id: DashboardCardId; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 1c0137a5..0d0e6379 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -15,6 +15,7 @@ import { AppRail } from "@/sidebar/AppRail"; import type { RailView } from "@/sidebar/AppRail"; import { HostsPanel } from "@/sidebar/HostsPanel"; import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel"; +import { SerialPanel } from "@/sidebar/SerialPanel"; import { SshToolsPanel } from "@/sidebar/SshToolsPanel"; import { SnippetsPanel } from "@/sidebar/SnippetsPanel"; import { HistoryPanel } from "@/sidebar/HistoryPanel"; @@ -22,7 +23,9 @@ import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel"; import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; +import { AlertsPanel } from "@/sidebar/AlertsPanel"; import { CredentialsPanel } from "@/sidebar/CredentialsPanel"; +import { TermixIdPanel } from "@/sidebar/TermixIdPanel"; import { SplitView } from "@/shell/SplitView"; import { renderTabContent } from "@/shell/tabUtils"; import { AlertManager } from "@/dashboard/panels/alerts/AlertManager"; @@ -35,6 +38,7 @@ import type { HostFolder, ThemeId, FontSizeId, + SerialConfig, } from "@/types/ui-types"; import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; @@ -54,67 +58,11 @@ import { } from "@/main-axios"; import { dbHealthMonitor } from "@/lib/db-health-monitor"; import type { SSHHostWithStatus } from "@/main-axios"; +import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel"; import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx"; - -function sshHostToHost(h: SSHHostWithStatus): Host { - return { - id: String(h.id), - name: h.name, - username: h.username, - ip: h.ip, - port: h.port, - folder: h.folder ?? "", - online: h.status === "online", - cpu: 0, - ram: 0, - lastAccess: "", - tags: h.tags ?? [], - authType: h.authType, - password: h.password, - key: typeof h.key === "string" ? h.key : undefined, - keyPassword: h.keyPassword, - keyType: h.keyType, - credentialId: h.credentialId != null ? String(h.credentialId) : undefined, - notes: h.notes, - pin: h.pin ?? false, - macAddress: h.macAddress, - enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType), - enableTerminal: h.enableTerminal ?? true, - enableTunnel: h.enableTunnel ?? false, - enableFileManager: h.enableFileManager ?? true, - enableDocker: h.enableDocker ?? false, - enableProxmox: h.enableProxmox ?? false, - enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor --- - proxmoxConfig: (h.proxmoxConfig as Host["proxmoxConfig"]) ?? null, - enableRdp: h.enableRdp ?? h.connectionType === "rdp", - enableVnc: h.enableVnc ?? h.connectionType === "vnc", - enableTelnet: h.enableTelnet ?? h.connectionType === "telnet", - sshPort: h.port, - rdpPort: 3389, - vncPort: 5900, - telnetPort: 23, - quickActions: (h.quickActions ?? []).map((a) => ({ - name: a.name, - snippetId: String(a.snippetId), - })), - jumpHosts: (h.jumpHosts ?? []).map((j) => ({ - hostId: String(j.hostId), - })), - serverTunnels: [], - defaultPath: h.defaultPath, - terminalConfig: h.terminalConfig as Host["terminalConfig"], - useSocks5: h.useSocks5, - socks5Host: h.socks5Host, - socks5Port: h.socks5Port, - socks5Username: h.socks5Username, - socks5Password: h.socks5Password, - socks5ProxyChain: h.socks5ProxyChain ?? [], - statsConfig: (typeof h.statsConfig === "string" - ? JSON.parse(h.statsConfig) - : h.statsConfig) as Host["statsConfig"], - }; -} +import { sshHostToHost } from "@/sidebar/HostManagerData"; +import { resolveHostTabType } from "@/lib/host-connection-tabs"; function buildHostTree( hosts: SSHHostWithStatus[], @@ -221,6 +169,9 @@ export function AppShell({ }); const [sidebarDragging, setSidebarDragging] = useState(false); const [sidebarEditing, setSidebarEditing] = useState(false); + const [isAppFullscreen, setIsAppFullscreen] = useState( + () => !!document.fullscreenElement, + ); useEffect(() => { localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); @@ -255,6 +206,34 @@ export function AppShell({ .catch(() => setIsAdmin(false)); }, []); + const toggleAppFullscreen = useCallback(async () => { + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + return; + } + + if (!document.fullscreenEnabled) { + toast.error("Fullscreen is not supported by this browser"); + return; + } + + await document.documentElement.requestFullscreen(); + } catch { + toast.error("Unable to toggle fullscreen mode"); + } + }, []); + + useEffect(() => { + const handleFullscreenChange = () => { + setIsAppFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + const lastShiftTime = useRef(0); const tabsRef = useRef(tabs); const activeTabIdRef = useRef(activeTabId); @@ -325,7 +304,9 @@ export function AppShell({ const sidebarTitle: Record = { hosts: "Hosts", credentials: "Credentials", + "termix-id": t("nav.termixId"), "quick-connect": "Quick Connect", + serial: t("nav.serial"), "ssh-tools": "SSH Tools", snippets: "Snippets", history: "History", @@ -334,6 +315,7 @@ export function AppShell({ connections: t("nav.connections"), "user-profile": "User Profile", "admin-settings": "Admin Settings", + alerts: t("nav.alerts"), }; // Double-shift opens command palette @@ -355,6 +337,14 @@ export function AppShell({ // without going through synthetic DOM events (which are unreliable). useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === "KeyF") { + e.preventDefault(); + toggleAppFullscreen(); + return; + } + } + // Ctrl+Shift+\ — toggle 2-way split (side by side) if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") { e.preventDefault(); @@ -607,6 +597,7 @@ export function AppShell({ "showHostTags", "hostTrayOnClick", "pinAppRail", + "expandAppRailOnHover", "defaultSnippetFoldersCollapsed", "confirmSnippetExecution", "disableUpdateCheck", @@ -658,8 +649,20 @@ export function AppShell({ "hostTrayOnClick", String(prefs.hostTrayOnClick), ); - if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) + if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) { localStorage.setItem("pinAppRail", String(prefs.pinAppRail)); + window.dispatchEvent(new Event("pinAppRailChanged")); + } + if ( + prefs.expandAppRailOnHover !== null && + prefs.expandAppRailOnHover !== undefined + ) { + localStorage.setItem( + "expandAppRailOnHover", + String(prefs.expandAppRailOnHover), + ); + window.dispatchEvent(new Event("expandAppRailOnHoverChanged")); + } if ( prefs.foldersCollapsed !== null && prefs.foldersCollapsed !== undefined @@ -841,8 +844,11 @@ export function AppShell({ host, openedAt: new Date(saved.createdAt).getTime(), restoredSessionId, - terminalRef: - saved.tabType === "terminal" ? createRef() : undefined, + terminalRef: SESSION_TAB_TYPES.includes( + saved.tabType as TabType, + ) + ? createRef() + : undefined, }); } @@ -909,6 +915,7 @@ export function AppShell({ restoredSessionId: string | null; savedLabel?: string; initialFilePath?: string; + serialConfig?: SerialConfig; }, ) { const tabId = `${host.name}-${type}-${Date.now()}`; @@ -918,12 +925,13 @@ export function AppShell({ ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`); const openedAt = Date.now(); - const ref = type === "terminal" ? createRef() : undefined; + const ref = SESSION_TAB_TYPES.includes(type) ? createRef() : undefined; if (ref) terminalRefs.current.set(tabId, ref); let finalLabel = host.name; const savedLabel = restore?.savedLabel; const initialFilePath = restore?.initialFilePath; + const serialConfig = restore?.serialConfig; // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label const isCustomLabel = savedLabel != null && @@ -946,6 +954,7 @@ export function AppShell({ terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, initialFilePath, + serialConfig, }, ]; } @@ -977,6 +986,7 @@ export function AppShell({ terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, initialFilePath, + serialConfig, }, ]; }); @@ -994,17 +1004,7 @@ export function AppShell({ }, []); function connectHost(host: Host, preferredType?: TabType) { - const type: TabType = - preferredType ?? - (host.enableSsh - ? "terminal" - : host.enableRdp - ? "rdp" - : host.enableVnc - ? "vnc" - : host.enableTelnet - ? "telnet" - : "terminal"); + const type = resolveHostTabType(host, preferredType); // --- tmux-monitor --- singleton tab, not a per-host tab if (type === "tmux_monitor") { openSingletonTab(type, undefined, host); @@ -1013,6 +1013,50 @@ export function AppShell({ openTab(host, type); } + function openSerialTab(config: SerialConfig) { + const pseudoHost: Host = { + id: `serial-${Date.now()}`, + name: config.path + ? `${config.path} (${config.baudRate})` + : `Serial (${config.baudRate})`, + username: "", + ip: "", + port: 0, + folder: "", + online: false, + cpu: null, + ram: null, + lastAccess: new Date().toISOString(), + authType: "none", + enableTerminal: false, + enableCommandHistory: false, + enableTunnel: false, + enableFileManager: false, + enableDocker: false, + enableProxmox: false, + enableTmuxMonitor: false, + enableSsh: false, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + sshPort: 22, + rdpPort: 3389, + vncPort: 5900, + telnetPort: 23, + serverTunnels: [], + quickActions: [], + }; + const instanceId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + openTab(pseudoHost, "serial", { + instanceId, + restoredSessionId: null, + serialConfig: config, + }); + } + const openSingletonTab = useCallback( // --- tmux-monitor --- (added optional `host` so tmux_monitor can open // with a preselected host; existing callers are unaffected) @@ -1060,6 +1104,7 @@ export function AppShell({ tunnel: t("nav.tunnels"), network_graph: t("nav.networkGraph"), tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor --- + homepage: t("nav.homepage"), }; setTabs((prev) => { const existing = prev.find((t) => t.id === id); @@ -1094,7 +1139,42 @@ export function AppShell({ [t], ); - const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; + const SESSION_TAB_TYPES: TabType[] = [ + "terminal", + "rdp", + "vnc", + "telnet", + "serial", + ]; + const ACTIVE_CLOSE_CONFIRM_TYPES: TabType[] = SESSION_TAB_TYPES; + + const getTabCloseLabel = useCallback((tab: Tab) => { + return tab.customLabel || tab.label || tab.host?.name || String(tab.id); + }, []); + + const isActiveConnectionTab = useCallback((tab: Tab) => { + if (!ACTIVE_CLOSE_CONFIRM_TYPES.includes(tab.type)) return false; + return tab.terminalRef?.current?.isConnected?.() === true; + }, []); + + const hasActiveConnection = useCallback(() => { + return tabsRef.current.some(isActiveConnectionTab); + }, [isActiveConnectionTab]); + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasActiveConnection()) return; + + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [hasActiveConnection]); function doCloseTab(id: string) { const tabToClose = tabs.find((t) => t.id === id); @@ -1148,20 +1228,37 @@ export function AppShell({ function closeTab(id: string) { const tab = tabs.find((t) => t.id === id); const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; - if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) { - toast(t("nav.confirmClose"), { - duration: 5000, - action: { - label: t("nav.close"), - onClick: () => doCloseTab(id), + if (tab && confirmEnabled && isActiveConnectionTab(tab)) { + const closeLabel = getTabCloseLabel(tab); + const toastId = `close-tab-${id}`; + toast( + t("nav.confirmCloseHost", { + host: closeLabel, + defaultValue: `Close ${closeLabel}?`, + }), + { + id: toastId, + duration: 8000, + action: { + label: t("nav.close"), + onClick: () => { + toast.dismiss(toastId); + doCloseTab(id); + }, + }, + cancel: { + label: t("nav.cancel"), + onClick: () => toast.dismiss(toastId), + }, }, - cancel: { - label: t("nav.cancel"), - onClick: () => {}, - }, - }); + ); return; } + + if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) { + toast.dismiss(`close-tab-${id}`); + } + doCloseTab(id); } @@ -1366,6 +1463,21 @@ export function AppShell({ />
+ {railView === "termix-id" && ( +
+ +
+ )} + + {railView === "serial" && ( + { + openSerialTab(config); + if (isMobile) setSidebarOpen(false); + }} + /> + )} + {railView === "quick-connect" && ( { @@ -1481,6 +1593,12 @@ export function AppShell({ )} + + {railView === "alerts" && ( +
+ +
+ )} ); @@ -1517,7 +1635,7 @@ export function AppShell({ ); return ( - <> +
{/* Skinny icon rail — desktop only, hidden on mobile */}
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */} @@ -1658,7 +1778,16 @@ export function AppShell({ restoredSessionId: null, initialFilePath: filePath, }), - (host, path) => openTab(host, "files"), + (host, _path) => openTab(host, "files"), + (host, path) => + openTab(host, "terminal", { + instanceId: + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + restoredSessionId: null, + initialFilePath: path, + }), renameTab, ), tabNode, @@ -1708,6 +1837,6 @@ export function AppShell({ /> - + ); } diff --git a/src/ui/api/alerts-api.ts b/src/ui/api/alerts-api.ts new file mode 100644 index 00000000..82cb342c --- /dev/null +++ b/src/ui/api/alerts-api.ts @@ -0,0 +1,133 @@ +import { rbacApi } from "@/main-axios"; + +export interface NotificationChannel { + id: number; + userId: string; + name: string; + type: "webhook" | "ntfy"; + config: string; + enabled: boolean; + createdAt: string; +} + +export interface AlertRule { + id: number; + userId: string; + hostId: number | null; + name: string; + enabled: boolean; + triggerType: string; + thresholdValue: number | null; + thresholdDurationSeconds: number | null; + cooldownMinutes: number; + createdAt: string; + updatedAt: string; + channelIds: number[]; +} + +export interface AlertFiring { + id: number; + userId: string; + ruleId: number; + hostId: number; + hostName: string; + firedAt: string; + resolvedAt: string | null; + value: number | null; + message: string; + severity: "info" | "warning" | "critical"; + acknowledged: boolean; + ruleName?: string; +} + +export async function getNotificationChannels(): Promise< + NotificationChannel[] +> { + const res = await rbacApi.get("/notification-channels"); + return res.data; +} + +export async function createNotificationChannel( + data: Partial, +): Promise { + const res = await rbacApi.post("/notification-channels", data); + return res.data; +} + +export async function updateNotificationChannel( + id: number, + data: Partial, +): Promise { + const res = await rbacApi.put(`/notification-channels/${id}`, data); + return res.data; +} + +export async function deleteNotificationChannel(id: number): Promise { + await rbacApi.delete(`/notification-channels/${id}`); +} + +export async function testNotificationChannel(id: number): Promise { + await rbacApi.post(`/notification-channels/${id}/test`); +} + +function mapRule(r: Record): AlertRule { + return { + id: r.id as number, + userId: (r.user_id ?? r.userId) as string, + hostId: (r.host_id ?? r.hostId ?? null) as number | null, + name: r.name as string, + enabled: Boolean(r.enabled), + triggerType: (r.trigger_type ?? r.triggerType) as string, + thresholdValue: (r.threshold_value ?? r.thresholdValue ?? null) as + | number + | null, + thresholdDurationSeconds: (r.threshold_duration_seconds ?? + r.thresholdDurationSeconds ?? + null) as number | null, + cooldownMinutes: (r.cooldown_minutes ?? r.cooldownMinutes) as number, + createdAt: (r.created_at ?? r.createdAt) as string, + updatedAt: (r.updated_at ?? r.updatedAt) as string, + channelIds: Array.isArray(r.channels) ? (r.channels as number[]) : [], + }; +} + +export async function getAlertRules(): Promise { + const res = await rbacApi.get("/alert-rules"); + return (res.data as Record[]).map(mapRule); +} + +export async function createAlertRule( + data: Partial & { channels?: number[] }, +): Promise { + const res = await rbacApi.post("/alert-rules", data); + return mapRule(res.data as Record); +} + +export async function updateAlertRule( + id: number, + data: Partial & { channels?: number[] }, +): Promise { + const res = await rbacApi.put(`/alert-rules/${id}`, data); + return mapRule(res.data as Record); +} + +export async function deleteAlertRule(id: number): Promise { + await rbacApi.delete(`/alert-rules/${id}`); +} + +export async function getAlertFirings(opts?: { + limit?: number; + offset?: number; + acknowledged?: boolean; +}): Promise { + const res = await rbacApi.get("/alert-firings", { params: opts }); + return (res.data as { firings: AlertFiring[] }).firings ?? res.data; +} + +export async function acknowledgeAlertFiring(id: number): Promise { + await rbacApi.post(`/alert-firings/${id}/acknowledge`); +} + +export async function acknowledgeAllAlertFirings(): Promise { + await rbacApi.post("/alert-firings/acknowledge-all"); +} diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts index 02976480..7c0f1e7b 100644 --- a/src/ui/api/credentials-api.ts +++ b/src/ui/api/credentials-api.ts @@ -96,7 +96,7 @@ export async function getSSHHostWithCredentials( export async function getHostPassword( hostId: number, - field: "password" | "sudoPassword" = "password", + field: "password" | "sudoPassword" | "vncPassword" = "password", ): Promise { try { const response = await sshHostApi.get( diff --git a/src/ui/api/dashboard-api.ts b/src/ui/api/dashboard-api.ts index 281eda40..1e5abfc0 100644 --- a/src/ui/api/dashboard-api.ts +++ b/src/ui/api/dashboard-api.ts @@ -1,4 +1,5 @@ import { dashboardApi, handleApiError } from "@/main-axios"; +import { normalizeServiceLinkUrl } from "@/lib/service-link-url"; // DASHBOARD API // ============================================================================ @@ -105,7 +106,10 @@ export async function createServiceLink( url: string, ): Promise { try { - const response = await dashboardApi.post("/service-links", { label, url }); + const response = await dashboardApi.post("/service-links", { + label, + url: normalizeServiceLinkUrl(url), + }); return response.data; } catch (error) { throw handleApiError(error, "create service link"); @@ -128,7 +132,13 @@ export async function updateServiceLink( updates: { label?: string; url?: string }, ): Promise { try { - const response = await dashboardApi.put(`/service-links/${id}`, updates); + const response = await dashboardApi.put(`/service-links/${id}`, { + ...updates, + url: + updates.url !== undefined + ? normalizeServiceLinkUrl(updates.url) + : undefined, + }); return response.data; } catch (error) { throw handleApiError(error, "update service link"); diff --git a/src/ui/api/homepage-api.ts b/src/ui/api/homepage-api.ts new file mode 100644 index 00000000..e47f0f1c --- /dev/null +++ b/src/ui/api/homepage-api.ts @@ -0,0 +1,81 @@ +import { homepageApi, handleApiError } from "@/main-axios"; +import type { + HomepageItemRow, + HomepageLayoutData, + HomepageLayoutRow, + WidgetTypeId, +} from "@/types/homepage-types"; + +export async function getHomepageItems(): Promise { + try { + const res = await homepageApi.get("/items"); + return res.data; + } catch (error) { + throw handleApiError(error, "fetch homepage items"); + } +} + +export async function createHomepageItem(data: { + typeId: WidgetTypeId; + title?: string | null; + config?: Record; +}): Promise { + try { + const res = await homepageApi.post("/items", data); + return res.data; + } catch (error) { + throw handleApiError(error, "create homepage item"); + } +} + +export async function updateHomepageItem( + id: number, + data: { + title?: string | null; + config?: Record; + }, +): Promise { + try { + const res = await homepageApi.put(`/items/${id}`, data); + return res.data; + } catch (error) { + throw handleApiError(error, "update homepage item"); + } +} + +export async function deleteHomepageItem(id: number): Promise { + try { + await homepageApi.delete(`/items/${id}`); + } catch (error) { + throw handleApiError(error, "delete homepage item"); + } +} + +export async function getHomepageLayout(): Promise { + try { + const res = await homepageApi.get("/layout"); + return res.data; + } catch (error) { + throw handleApiError(error, "fetch homepage layout"); + } +} + +export async function saveHomepageLayout( + layout: HomepageLayoutData, +): Promise { + try { + const res = await homepageApi.put("/layout", layout); + return res.data; + } catch (error) { + throw handleApiError(error, "save homepage layout"); + } +} + +export function getHomepageFaviconUrl(url: string): string { + try { + const base = (homepageApi.defaults.baseURL ?? "").replace(/\/$/, ""); + return `${base}/favicon?url=${encodeURIComponent(url)}`; + } catch { + return ""; + } +} diff --git a/src/ui/api/host-metrics-api.ts b/src/ui/api/host-metrics-api.ts index 51599f3e..f45b89e8 100644 --- a/src/ui/api/host-metrics-api.ts +++ b/src/ui/api/host-metrics-api.ts @@ -1,6 +1,43 @@ import { handleApiError, statsApi } from "@/main-axios"; import type { HostMetricsLayout } from "@/types/host-metrics"; +export interface MetricsHistoryRow { + ts: string; + cpu_percent: number | null; + mem_percent: number | null; + disk_percent: number | null; + net_rx_bytes: number | null; + net_tx_bytes: number | null; +} + +export interface MetricsHistoryResponse { + rows: MetricsHistoryRow[]; + fromTs: string; + toTs: string; +} + +export async function getMetricsHistory( + hostId: number, + opts: { range?: string; from?: string; to?: string }, +): Promise { + const res = await statsApi.get(`/metrics/history/${hostId}`, { + params: opts, + }); + return res.data as MetricsHistoryResponse; +} + +export async function getMetricsHistoryRetention(): Promise { + const res = await statsApi.get("/global-settings/history"); + return (res.data as { metricsHistoryRetentionDays: number }) + .metricsHistoryRetentionDays; +} + +export async function saveMetricsHistoryRetention(days: number): Promise { + await statsApi.post("/global-settings/history", { + metricsHistoryRetentionDays: days, + }); +} + /** * Host Metrics layout persistence (server-synced per user/host) + the manager * card endpoints. All routes live under the `/host-metrics/*` prefix on the diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index 880bf993..c64f2e06 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -89,6 +89,7 @@ export interface UserPreferences { showHostTags?: boolean | null; hostTrayOnClick?: boolean | null; pinAppRail?: boolean | null; + expandAppRailOnHover?: boolean | null; foldersCollapsed?: boolean | null; confirmSnippetExecution?: boolean | null; disableUpdateCheck?: boolean | null; diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts index a7c047a6..bd3c17bd 100644 --- a/src/ui/api/ssh-file-operations-api.ts +++ b/src/ui/api/ssh-file-operations-api.ts @@ -24,6 +24,27 @@ type ConnectErrorResponse = { reason?: string; }; +function buildFileManagerUrl(path: string): string { + const baseURL = String(fileManagerApi.defaults.baseURL || ""); + return `${baseURL.replace(/\/$/, "")}${path}`; +} + +function triggerBlobDownload(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.style.display = "none"; + + document.body.appendChild(link); + link.click(); + + window.setTimeout(() => { + link.remove(); + URL.revokeObjectURL(url); + }, 1000); +} + // SSH FILE OPERATIONS // ============================================================================ @@ -348,8 +369,74 @@ export async function uploadSSHFile( file: File, hostId?: number, userId?: string, + onChunkProgress?: (progress: { + chunkIndex: number; + totalChunks: number; + bytesSent: number; + totalBytes: number; + }) => void, ): Promise> { + // Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB) + // crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox, + // surfacing as "DOMException: File could not be read" inside FileManager-*.js. + // For larger files we slice into <=8MB chunks and upload each one separately, + // never materializing the whole file in JS memory. + const CHUNK_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; // 1.5 GiB + const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB + try { + if (file.size > CHUNK_THRESHOLD_BYTES) { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES); + fileLogger.info("Starting chunked upload", { + operation: "file_upload_chunked_start", + fileName, + fileSize: file.size, + totalChunks, + chunkSize: CHUNK_SIZE_BYTES, + }); + + let bytesSent = 0; + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE_BYTES; + const end = Math.min(start + CHUNK_SIZE_BYTES, file.size); + const chunkBlob = file.slice(start, end); + + const form = new FormData(); + form.append("sessionId", sessionId); + form.append("path", path); + form.append("fileName", fileName); + form.append("chunkIndex", String(i)); + form.append("totalChunks", String(totalChunks)); + form.append("totalSize", String(file.size)); + form.append("chunk", chunkBlob, fileName); + + const response = await fileManagerApi.post( + "/ssh/uploadFileChunk", + form, + { timeout: 0 }, + ); + + bytesSent = end; + onChunkProgress?.({ + chunkIndex: i, + totalChunks, + bytesSent, + totalBytes: file.size, + }); + + if (i === totalChunks - 1) { + fileLogger.success("Chunked upload completed", { + operation: "file_upload_chunked_complete", + fileName, + fileSize: file.size, + totalChunks, + }); + return response.data; + } + } + return { message: "File uploaded successfully", chunked: true }; + } + const form = new FormData(); form.append("sessionId", sessionId); form.append("path", path); @@ -357,9 +444,13 @@ export async function uploadSSHFile( if (userId !== undefined) form.append("userId", userId); form.append("file", file, fileName); - const response = await fileManagerApi.post("/ssh/uploadFileStream", form, { - timeout: 0, - }); + const response = await fileManagerApi.postForm( + "/ssh/uploadFileStream", + form, + { + timeout: 0, + }, + ); return response.data; } catch (error) { handleApiError(error, "upload SSH file"); @@ -400,12 +491,7 @@ export async function downloadSSHFileStream( ); const blob = response.data as Blob; const fileName = filePath.split("/").pop() || "download"; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(blob, fileName); } export async function createSSHFile( diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 303bf491..9b1c574a 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -88,6 +88,7 @@ export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> { export async function bulkImportSSHHosts( hosts: SSHHostData[], overwrite = false, + credentials?: Record[], ): Promise<{ message: string; success: number; @@ -100,6 +101,7 @@ export async function bulkImportSSHHosts( const response = await sshHostApi.post("/bulk-import", { hosts, overwrite, + ...(credentials ? { credentials } : {}), }); return response.data; } catch (error) { @@ -190,11 +192,27 @@ export async function exportSSHHostWithCredentials( } } -export async function exportAllSSHHosts(): Promise<{ +export function exportAllSSHHosts(): Promise<{ + hosts: SSHHost[]; +}>; +export function exportAllSSHHosts(options: { share: true }): Promise<{ + version: string; + exportedAt: string; + credentials: Record[]; + hosts: SSHHost[]; +}>; +export async function exportAllSSHHosts(options?: { + share?: boolean; +}): Promise<{ + version?: string; + exportedAt?: string; + credentials?: Record[]; hosts: SSHHost[]; }> { try { - const response = await sshHostApi.get("/db/hosts/export"); + const response = await sshHostApi.get( + options?.share ? "/db/hosts/export?share=1" : "/db/hosts/export", + ); return response.data; } catch (error) { handleApiError(error, "export all SSH hosts"); diff --git a/src/ui/api/termix-id-api.ts b/src/ui/api/termix-id-api.ts new file mode 100644 index 00000000..04a193c5 --- /dev/null +++ b/src/ui/api/termix-id-api.ts @@ -0,0 +1,217 @@ +import { authApi, handleApiError } from "@/main-axios"; + +export interface TermixIdentity { + id: number; + userId: string; + handle: string; + description: string | null; + resolverPath?: string; + createdAt: string; + updatedAt: string; +} + +export interface TermixIdentityKey { + id: number; + identityId: number; + userId: string; + publicKey: string; + keyType: string; + algorithm: string; + label: string | null; + comment: string | null; + source: string; + credentialId: number | null; + enabled: boolean; + createdAt: string; +} + +export interface TermixIdMe { + identity: TermixIdentity | null; + keys: TermixIdentityKey[]; +} + +export async function getMyTermixId(): Promise { + try { + const response = await authApi.get("/termix-id/me"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch Termix ID"); + } +} + +export async function checkTermixIdHandle( + handle: string, +): Promise<{ available: boolean; valid: boolean }> { + try { + const response = await authApi.get( + `/termix-id/check/${encodeURIComponent(handle)}`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "check handle"); + } +} + +export async function createTermixId( + handle: string, + description?: string, +): Promise { + try { + const response = await authApi.post("/termix-id", { handle, description }); + return response.data; + } catch (error) { + throw handleApiError(error, "create Termix ID"); + } +} + +export async function updateTermixId(data: { + handle?: string; + description?: string; +}): Promise { + try { + const response = await authApi.put("/termix-id", data); + return response.data; + } catch (error) { + throw handleApiError(error, "update Termix ID"); + } +} + +export async function deleteTermixId(): Promise { + try { + await authApi.delete("/termix-id"); + } catch (error) { + throw handleApiError(error, "delete Termix ID"); + } +} + +export async function addTermixIdKey(data: { + publicKey?: string; + credentialId?: number; + label?: string; +}): Promise { + try { + const response = await authApi.post("/termix-id/keys", data); + return response.data; + } catch (error) { + throw handleApiError(error, "add key"); + } +} + +export interface GeneratedKey { + key: TermixIdentityKey; + privateKey: string; + publicKey: string; + credentialId: number | null; +} + +export async function generateTermixIdKey( + type: "ed25519" | "rsa" = "ed25519", + saveCredential = true, +): Promise { + try { + const response = await authApi.post("/termix-id/keys/generate", { + type, + saveCredential, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "generate key"); + } +} + +export async function setTermixIdKeyEnabled( + id: number, + enabled: boolean, +): Promise { + try { + const response = await authApi.patch(`/termix-id/keys/${id}`, { enabled }); + return response.data; + } catch (error) { + throw handleApiError(error, "update key"); + } +} + +export async function deleteTermixIdKey(id: number): Promise { + try { + await authApi.delete(`/termix-id/keys/${id}`); + } catch (error) { + throw handleApiError(error, "delete key"); + } +} + +export interface TermixIdCa { + publicKey: string; + validityDays: number; + resolverPath: string; +} + +export interface IssuedCertificate { + certificate: string; + keyId: string; + validBefore: number; + principals: string[]; + validityDays: number; +} + +export async function getMyCa(): Promise<{ ca: TermixIdCa | null }> { + try { + const response = await authApi.get("/termix-id/ca"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch CA"); + } +} + +export async function createCa(validityDays?: number): Promise { + try { + const response = await authApi.post("/termix-id/ca", { validityDays }); + return response.data; + } catch (error) { + throw handleApiError(error, "create CA"); + } +} + +export async function rotateCa(validityDays?: number): Promise { + try { + const response = await authApi.post("/termix-id/ca/rotate", { + validityDays, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "rotate CA"); + } +} + +export async function deleteCa(): Promise { + try { + await authApi.delete("/termix-id/ca"); + } catch (error) { + throw handleApiError(error, "delete CA"); + } +} + +export async function issueCertificate( + keyId: number, + opts: { principals?: string[]; validityDays?: number } = {}, +): Promise { + try { + const response = await authApi.post( + `/termix-id/keys/${keyId}/certificate`, + opts, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "issue certificate"); + } +} + +export async function getLinkedCredentialIds(): Promise<{ + credentialIds: number[]; +}> { + try { + const response = await authApi.get("/termix-id/linked-credentials"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch linked credentials"); + } +} diff --git a/src/ui/api/vault-profiles-api.ts b/src/ui/api/vault-profiles-api.ts new file mode 100644 index 00000000..fbecea0e --- /dev/null +++ b/src/ui/api/vault-profiles-api.ts @@ -0,0 +1,60 @@ +import { authApi, handleApiError } from "@/main-axios"; + +export interface VaultProfilePayload { + name: string; + description?: string | null; + folder?: string | null; + tags?: string[]; + vaultAddr: string; + vaultNamespace?: string | null; + oidcMount?: string | null; + oidcRole?: string | null; + sshMount?: string | null; + sshRole: string; + validPrincipals?: string | null; + keyType?: string | null; + shared?: boolean; +} + +export async function getVaultProfiles(): Promise[]> { + try { + const response = await authApi.get("/vault/profiles"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch vault profiles"); + } +} + +export async function createVaultProfile( + payload: VaultProfilePayload, +): Promise> { + try { + const response = await authApi.post("/vault/profiles", payload); + return response.data; + } catch (error) { + throw handleApiError(error, "create vault profile"); + } +} + +export async function updateVaultProfile( + id: number, + payload: Partial, +): Promise> { + try { + const response = await authApi.put(`/vault/profiles/${id}`, payload); + return response.data; + } catch (error) { + throw handleApiError(error, "update vault profile"); + } +} + +export async function deleteVaultProfile( + id: number, +): Promise> { + try { + const response = await authApi.delete(`/vault/profiles/${id}`); + return response.data; + } catch (error) { + throw handleApiError(error, "delete vault profile"); + } +} diff --git a/src/ui/api/webauthn-api.ts b/src/ui/api/webauthn-api.ts new file mode 100644 index 00000000..76f2f5bc --- /dev/null +++ b/src/ui/api/webauthn-api.ts @@ -0,0 +1,119 @@ +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from "@simplewebauthn/browser"; +import { + startAuthentication, + startRegistration, +} from "@simplewebauthn/browser"; +import { authApi, handleApiError, type AuthResponse } from "@/main-axios"; + +export type WebAuthnUserVerification = "discouraged" | "preferred" | "required"; + +export type WebAuthnCredentialSummary = { + id: string; + name: string; + deviceType?: string | null; + backedUp: boolean; + transports: string[]; + userVerification: WebAuthnUserVerification; + createdAt: string; + lastUsedAt?: string | null; +}; + +type RegistrationOptionsResponse = { + options: PublicKeyCredentialCreationOptionsJSON; + challengeId: string; +}; + +type AuthenticationOptionsResponse = { + options: PublicKeyCredentialRequestOptionsJSON; + challengeId: string; +}; + +export async function listWebAuthnCredentials(): Promise<{ + credentials: WebAuthnCredentialSummary[]; +}> { + try { + const response = await authApi.get("/users/webauthn/credentials"); + return response.data; + } catch (error) { + throw handleApiError(error, "list passkeys"); + } +} + +export async function registerWebAuthnCredential( + name: string, + userVerification: WebAuthnUserVerification, +): Promise<{ success: boolean }> { + try { + const optionsResponse = await authApi.post( + "/users/webauthn/register/options", + { userVerification }, + ); + const credential = await startRegistration({ + optionsJSON: optionsResponse.data.options, + }); + const verifyResponse = await authApi.post( + "/users/webauthn/register/verify", + { + challengeId: optionsResponse.data.challengeId, + name, + response: credential as RegistrationResponseJSON, + }, + ); + return verifyResponse.data; + } catch (error) { + throw handleApiError(error, "register passkey"); + } +} + +export async function authenticateWithWebAuthn( + username: string, + rememberMe: boolean, + userVerification: WebAuthnUserVerification = "preferred", +): Promise { + try { + const optionsResponse = await authApi.post( + "/users/webauthn/authenticate/options", + { + username: username.trim() || undefined, + userVerification, + }, + ); + const credential = await startAuthentication({ + optionsJSON: optionsResponse.data.options, + }); + const verifyResponse = await authApi.post( + "/users/webauthn/authenticate/verify", + { + challengeId: optionsResponse.data.challengeId, + rememberMe, + response: credential as AuthenticationResponseJSON, + }, + ); + + if (verifyResponse.data.token) { + localStorage.setItem("jwt", verifyResponse.data.token); + } + + return verifyResponse.data; + } catch (error) { + throw handleApiError(error, "authenticate with passkey"); + } +} + +export async function deleteWebAuthnCredential( + credentialId: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete( + `/users/webauthn/credentials/${credentialId}`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "delete passkey"); + } +} diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 8bb58e19..db4bad3d 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -278,6 +278,27 @@ export function Auth({ onLogin }: AuthProps) { } }, [rememberMe]); + useEffect(() => { + if (!isInElectronWebView()) return; + + const handleOIDCSystemBrowserResult = (event: MessageEvent) => { + if (event.source !== window.parent) return; + if (!event.data || typeof event.data !== "object") return; + if (event.data.type !== "OIDC_SYSTEM_BROWSER_AUTH_RESULT") return; + + const providerId = + typeof event.data.providerId === "number" ? event.data.providerId : -1; + if (event.data.success) return; + + setProviderLoading((prev) => ({ ...prev, [providerId]: false })); + toast.error(event.data.error || t("errors.failedOidcLogin")); + }; + + window.addEventListener("message", handleOIDCSystemBrowserResult); + return () => + window.removeEventListener("message", handleOIDCSystemBrowserResult); + }, [t]); + useEffect(() => { getRegistrationAllowed() .then((res) => setRegistrationAllowed(res.allowed)) @@ -770,6 +791,7 @@ export function Auth({ onLogin }: AuthProps) { source: "oidc_request", authUrl, callbackPort, + providerId: loadingKey, }, "*", ); diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index 8bd003e2..b49cb358 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -63,8 +63,15 @@ export function ElectronLoginForm({ try { if (event.source !== iframeRef.current?.contentWindow) return; if (!event.data || typeof event.data !== "object") return; - const { type, platform, source, token, authUrl, callbackPort } = - event.data; + const { + type, + platform, + source, + token, + authUrl, + callbackPort, + providerId, + } = event.data; if ( type === "AUTH_SUCCESS" && @@ -78,6 +85,20 @@ export function ElectronLoginForm({ // OIDC login requested from inside the iframe — open the system browser // so captcha stages (e.g. Cloudflare Turnstile) render correctly. if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) { + const sendResultToIframe = (result: { + success: boolean; + error?: string; + }) => { + iframeRef.current?.contentWindow?.postMessage( + { + type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", + source: "oidc_system_browser_auth", + providerId, + ...result, + }, + "*", + ); + }; const electronAPI = ( window as unknown as { electronAPI?: { @@ -92,23 +113,51 @@ export function ElectronLoginForm({ }; } ).electronAPI; - if (!electronAPI?.oidcSystemBrowserAuth) return; + if (!electronAPI?.oidcSystemBrowserAuth) { + sendResultToIframe({ + success: false, + error: t("errors.failedOidcLogin"), + }); + return; + } const result = await electronAPI.oidcSystemBrowserAuth( authUrl, callbackPort, ); if (result.success && result.token) { await handleAuthSuccess(result.token); + return; } + sendResultToIframe({ + success: false, + error: result.error || t("errors.failedOidcLogin"), + }); } - } catch { - // ignore + } catch (err) { + const providerId = + event.data && + typeof event.data === "object" && + typeof event.data.providerId === "number" + ? event.data.providerId + : undefined; + const error = + err instanceof Error ? err.message : t("errors.failedOidcLogin"); + iframeRef.current?.contentWindow?.postMessage( + { + type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT", + source: "oidc_system_browser_auth", + providerId, + success: false, + error, + }, + "*", + ); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [handleAuthSuccess]); + }, [handleAuthSuccess, t]); useEffect(() => { const iframe = iframeRef.current; diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx index fa0d829b..bd36ecf7 100644 --- a/src/ui/auth/LoginPage.tsx +++ b/src/ui/auth/LoginPage.tsx @@ -32,6 +32,7 @@ import { getOidcSilentLoginDefault, } from "@/main-axios"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; +import { authenticateWithWebAuthn } from "@/api/webauthn-api"; import type { SSOProviderPublic } from "@/types/index"; import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx"; @@ -117,6 +118,7 @@ export function Auth({ } }); const [loading, setLoading] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [internalLoggedIn, setInternalLoggedIn] = useState(false); const [firstUser, setFirstUser] = useState(false); @@ -657,6 +659,73 @@ export function Auth({ } } + async function handlePasskeyLogin() { + setPasskeyLoading(true); + try { + const res = await authenticateWithWebAuthn( + localUsername, + rememberMe, + "preferred", + ); + + if (res.requires_totp) { + setTotpRequired(true); + setTotpTempToken(res.temp_token || ""); + return; + } + + if (!res || !res.success) { + throw new Error(t("errors.loginFailed")); + } + + if (isInMobileWebView()) { + postMobileAuthSuccess(res.token || ""); + return; + } + + if (isInElectronWebView()) { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + source: "passkey_auth_component", + platform: "desktop", + token: res.token || null, + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + return; + } + + const meRes = await getUserInfo(); + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + toast.success(t("messages.loginSuccess")); + } catch (err: unknown) { + const error = err as { + message?: string; + response?: { data?: { error?: string } }; + }; + toast.error( + error?.response?.data?.error || + error?.message || + t("auth.passkeyLoginFailed"), + ); + } finally { + setPasskeyLoading(false); + } + } + const handleOIDCLogin = useCallback( async (providerId?: number) => { setOidcLoading(true); @@ -1320,8 +1389,10 @@ export function Auth({ const hasSignup = (passwordLoginAllowed || firstUser) && registrationAllowed; + const hasPasskey = !firstUser; const hasSso = ssoProviders.length > 0; - const hasAnyAuth = hasLogin || hasSignup || hasSso; + const hasAnyAuth = + hasLogin || hasSignup || hasPasskey || hasSso; if (!hasAnyAuth) { return ( @@ -1569,9 +1640,38 @@ export function Auth({ {!passwordLoginAllowed && !firstUser && tab === "login" ? ( -

- {t("auth.passwordLoginDisabledDesc")} -

+
+

+ {t("auth.passwordLoginDisabledDesc")} +

+
+ + + setLocalUsername(e.target.value) + } + disabled={passkeyLoading || loggedIn} + autoComplete="username webauthn" + /> +
+ +
) : ( <>
@@ -1588,6 +1688,7 @@ export function Auth({ setLocalUsername(e.target.value) } disabled={loading || loggedIn} + autoComplete="username webauthn" />
@@ -1666,6 +1767,21 @@ export function Auth({ {t("auth.resetPasswordButton")} )} + {tab === "login" && ( + + )} )} diff --git a/src/ui/components/charts/LineChart.tsx b/src/ui/components/charts/LineChart.tsx new file mode 100644 index 00000000..d59a9393 --- /dev/null +++ b/src/ui/components/charts/LineChart.tsx @@ -0,0 +1,292 @@ +import { useRef, useState, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; + +export interface LineChartSeries { + key: string; + label: string; + /** CSS color string or var(--...) */ + color: string; + data: Array; +} + +interface LineChartProps { + series: LineChartSeries[]; + timestamps: string[]; + domain: [number, number]; + yFormatter?: (v: number) => string; + height?: number; + className?: string; +} + +const Y_LABEL_W = 44; +const X_LABEL_H = 20; +const TOP_PAD = 6; +const TICK_COUNT = 5; + +function parseTs(ts: string): Date { + return new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z"); +} + +function formatTimestamp(ts: string, rangeMs: number): string { + const d = parseTs(ts); + if (rangeMs <= 25 * 60 * 60 * 1000) { + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + return ( + d.toLocaleDateString([], { month: "short", day: "numeric" }) + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); +} + +function valToY(v: number, drawH: number, domain: [number, number]): number { + const [lo, hi] = domain; + const range = hi === lo ? 1 : hi - lo; + return ( + TOP_PAD + drawH - ((Math.min(hi, Math.max(lo, v)) - lo) / range) * drawH + ); +} + +function buildLinePath( + data: Array, + w: number, + drawH: number, + domain: [number, number], +): string { + const parts: string[] = []; + let inSeg = false; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (v == null) { + inSeg = false; + continue; + } + const x = (i / Math.max(1, data.length - 1)) * w; + const y = valToY(v, drawH, domain); + parts.push( + inSeg + ? `L${x.toFixed(1)},${y.toFixed(1)}` + : `M${x.toFixed(1)},${y.toFixed(1)}`, + ); + inSeg = true; + } + return parts.join(" "); +} + +export function LineChart({ + series, + timestamps, + domain, + yFormatter = (v) => String(v), + height = 160, + className, +}: LineChartProps) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + const [tooltip, setTooltip] = useState<{ x: number; index: number } | null>( + null, + ); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => + setWidth(entries[0].contentRect.width), + ); + ro.observe(el); + setWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + + const chartW = Math.max(0, width - Y_LABEL_W); + const drawH = height - TOP_PAD; + const svgH = height + X_LABEL_H; + + const rangeMs = + timestamps.length >= 2 + ? parseTs(timestamps[timestamps.length - 1]).getTime() - + parseTs(timestamps[0]).getTime() + : 0; + + const yTicks = (() => { + const [lo, hi] = domain; + const step = (hi - lo) / (TICK_COUNT - 1); + return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step); + })(); + + const xTickIndices: number[] = []; + if (timestamps.length > 1 && chartW > 0) { + const n = timestamps.length; + // Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px. + // Use at most 3 ticks for long labels, 5 for short, so they never crowd. + const longLabel = rangeMs > 25 * 60 * 60 * 1000; + const maxTicks = longLabel ? 3 : 5; + const count = Math.min(maxTicks, n); + for (let k = 0; k < count; k++) { + xTickIndices.push(Math.round((k / (count - 1)) * (n - 1))); + } + } + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (timestamps.length < 2 || chartW <= 0) return; + const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); + const relX = e.clientX - rect.left - Y_LABEL_W; + const index = Math.round( + Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1), + ); + setTooltip({ x: e.clientX - rect.left, index }); + }, + [timestamps.length, chartW], + ); + + return ( +
+ {width > 0 && ( + setTooltip(null)} + > + {/* Grid lines + Y labels */} + {yTicks.map((tick, ti) => { + const y = valToY(tick, drawH, domain); + return ( + + + + {yFormatter(tick)} + + + ); + })} + + {/* Left + bottom border */} + + + + {/* Series */} + {chartW > 0 && + series.map((s) => { + const d = buildLinePath(s.data, chartW, drawH, domain); + if (!d) return null; + return ( + + ); + })} + + {/* Crosshair */} + {tooltip !== null && timestamps.length > 1 && chartW > 0 && ( + + )} + + {/* X labels */} + {xTickIndices.map((i, li) => { + const isFirst = li === 0; + const isLast = li === xTickIndices.length - 1; + const x = + Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW; + return ( + + {timestamps[i] ? formatTimestamp(timestamps[i], rangeMs) : ""} + + ); + })} + + )} + + {/* Tooltip */} + {tooltip !== null && timestamps[tooltip.index] && ( +
width / 2 ? tooltip.x - 8 : tooltip.x + 8, + top: 28, + transform: tooltip.x > width / 2 ? "translateX(-100%)" : undefined, + }} + > +
+ {formatTimestamp(timestamps[tooltip.index], rangeMs)} +
+ {series.map((s) => { + const v = s.data[tooltip.index]; + return ( +
+ + {s.label}: + + {v != null ? yFormatter(v) : "--"} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx index f34e0812..bbbc7a18 100644 --- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx +++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx @@ -24,6 +24,7 @@ import { } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; import type { ProxmoxGuest } from "@/types/proxmox"; +import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; interface ProxmoxDiscoverDialogProps { open: boolean; @@ -118,8 +119,7 @@ export function ProxmoxDiscoverDialog({ try { // Prefer explicitly configured credential, then fall back to the host's own credential const credId = defaultCredentialId ?? discoveredCredentialId; - const resolvedAuthType = - defaultAuthType ?? (credId != null ? "credential" : "password"); + const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId); const toImport = guests .filter((g) => selected.has(g.vmid)) @@ -129,13 +129,7 @@ export function ProxmoxDiscoverDialog({ port: g.connectionType === "rdp" ? 3389 : 22, username: defaultUsername ?? "root", folder: importFolder, - authType: resolvedAuthType, - ...(resolvedAuthType === "credential" && credId != null - ? { - credentialId: credId, - overrideCredentialUsername: true, - } - : {}), + ...importAuth, enableTerminal: g.connectionType !== "rdp", enableFileManager: g.connectionType !== "rdp", enableTunnel: g.connectionType !== "rdp", diff --git a/src/ui/components/proxmox/proxmox-import-auth.test.ts b/src/ui/components/proxmox/proxmox-import-auth.test.ts new file mode 100644 index 00000000..b64bb5fc --- /dev/null +++ b/src/ui/components/proxmox/proxmox-import-auth.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveProxmoxImportAuth } from "./proxmox-import-auth"; + +describe("resolveProxmoxImportAuth", () => { + it("uses credential auth when a credential is available", () => { + expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({ + authType: "credential", + credentialId: 42, + overrideCredentialUsername: true, + }); + }); + + it("does not import password auth without a password secret", () => { + expect(resolveProxmoxImportAuth("password", null)).toEqual({ + authType: "none", + }); + }); + + it("does not import key auth without a private key secret", () => { + expect(resolveProxmoxImportAuth("key", undefined)).toEqual({ + authType: "none", + }); + }); + + it("keeps secretless auth types", () => { + expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({ + authType: "opkssh", + }); + }); +}); diff --git a/src/ui/components/proxmox/proxmox-import-auth.ts b/src/ui/components/proxmox/proxmox-import-auth.ts new file mode 100644 index 00000000..b4257b01 --- /dev/null +++ b/src/ui/components/proxmox/proxmox-import-auth.ts @@ -0,0 +1,33 @@ +const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]); +const SECRETLESS_AUTH_TYPES = new Set(["none", "opkssh", "tailscale", "vault"]); + +export type ProxmoxImportAuth = { + authType: string; + credentialId?: number; + overrideCredentialUsername?: boolean; +}; + +export function resolveProxmoxImportAuth( + defaultAuthType: string | undefined, + credentialId: number | null | undefined, +): ProxmoxImportAuth { + if (defaultAuthType === "credential" || (!defaultAuthType && credentialId)) { + return credentialId + ? { + authType: "credential", + credentialId, + overrideCredentialUsername: true, + } + : { authType: "none" }; + } + + if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) { + return { authType: defaultAuthType }; + } + + if (defaultAuthType && !SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)) { + return { authType: defaultAuthType }; + } + + return { authType: "none" }; +} diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index bb8d27b4..80ec56c5 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -5,6 +5,7 @@ import { Card } from "@/components/card"; import { Separator } from "@/components/separator"; import { Activity, + Check, Database, ExternalLink, GripHorizontal, @@ -43,76 +44,37 @@ import { createServiceLink, deleteServiceLink, } from "@/main-axios"; -import type { - RecentActivityItem, - SSHHostWithStatus, - ServiceLink, -} from "@/main-axios"; +import type { RecentActivityItem, ServiceLink } from "@/main-axios"; import { useTranslation } from "react-i18next"; import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard"; +import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard"; +import { HomepageCanvas } from "@/features/homepage/HomepageCanvas"; + +// Side-effect imports so homepage widgets register themselves +import "@/features/homepage/widgets/ServiceLinkWidget"; +import "@/features/homepage/widgets/ClockWidget"; +import "@/features/homepage/widgets/NotesWidget"; +import "@/features/homepage/widgets/BookmarkListWidget"; +import "@/features/homepage/widgets/HostStatusWidget"; +import "@/features/homepage/widgets/FolderWidget"; import { useStatusColorScheme, getStatusClasses, } from "@/hooks/use-status-color-scheme"; - -function sshHostToHost(h: SSHHostWithStatus): Host { - return { - id: String(h.id), - name: h.name, - username: h.username, - ip: h.ip, - port: h.port, - folder: h.folder ?? "", - online: h.status === "online", - cpu: 0, - ram: 0, - lastAccess: "", - tags: h.tags ?? [], - authType: h.authType, - password: h.password, - key: typeof h.key === "string" ? h.key : undefined, - keyPassword: h.keyPassword, - keyType: h.keyType, - credentialId: h.credentialId != null ? String(h.credentialId) : undefined, - notes: h.notes, - pin: h.pin ?? false, - macAddress: h.macAddress, - enableTerminal: h.enableTerminal ?? true, - enableTunnel: h.enableTunnel ?? false, - enableFileManager: h.enableFileManager ?? true, - enableDocker: h.enableDocker ?? false, - enableSsh: h.connectionType === "ssh" || !h.connectionType, - enableRdp: h.connectionType === "rdp", - enableVnc: h.connectionType === "vnc", - enableTelnet: h.connectionType === "telnet", - sshPort: h.port, - rdpPort: 3389, - vncPort: 5900, - telnetPort: 23, - quickActions: (h.quickActions ?? []).map((a) => ({ - name: a.name, - snippetId: String(a.snippetId), - })), - jumpHosts: (h.jumpHosts ?? []).map((j) => ({ - hostId: String(j.hostId), - })), - serverTunnels: [], - defaultPath: h.defaultPath, - terminalConfig: h.terminalConfig as Host["terminalConfig"], - useSocks5: h.useSocks5, - socks5Host: h.socks5Host, - socks5Port: h.socks5Port, - socks5Username: h.socks5Username, - socks5Password: h.socks5Password, - socks5ProxyChain: h.socks5ProxyChain ?? [], - }; -} +import { useServerStatus } from "@/lib/ServerStatusContext"; +import { sshHostToHost } from "@/sidebar/HostManagerData"; +import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; +import { + isValidServiceLinkUrl, + normalizeServiceLinkUrl, +} from "@/lib/service-link-url"; // ─── Types ──────────────────────────────────────────────────────────────────── type PanelId = "main" | "side"; type CardSlot = { + key: string; id: DashboardCardId; panel: PanelId; order: number; @@ -120,6 +82,7 @@ type CardSlot = { }; type DragState = { + key: string; id: DashboardCardId; sourcePanel: PanelId; sourceOrder: number; @@ -128,11 +91,35 @@ type DragState = { // ─── Default layout ─────────────────────────────────────────────────────────── const DEFAULT_SLOTS: CardSlot[] = [ - { id: "stats_bar", panel: "main", order: 0, height: 96 }, - { id: "counters_bar", panel: "main", order: 1, height: 48 }, - { id: "quick_actions", panel: "main", order: 2, height: 160 }, - { id: "host_status", panel: "main", order: 3, height: null }, - { id: "recent_activity", panel: "side", order: 0, height: null }, + { key: "stats_bar_0", id: "stats_bar", panel: "main", order: 0, height: 96 }, + { + key: "counters_bar_0", + id: "counters_bar", + panel: "main", + order: 1, + height: 48, + }, + { + key: "quick_actions_0", + id: "quick_actions", + panel: "main", + order: 2, + height: 160, + }, + { + key: "host_status_0", + id: "host_status", + panel: "main", + order: 3, + height: null, + }, + { + key: "recent_activity_0", + id: "recent_activity", + panel: "side", + order: 0, + height: null, + }, ]; // ─── Card components ────────────────────────────────────────────────────────── @@ -277,6 +264,25 @@ function QuickActionsCard({ }) { const { t } = useTranslation(); const pinnedHosts = hosts.filter((h) => h.pin); + const getConnectionEndpoint = (host: Host) => { + const type = getDefaultConnectionTab(host); + const port = + type === "rdp" + ? host.rdpPort + : type === "vnc" + ? host.vncPort + : type === "telnet" + ? host.telnetPort + : host.sshPort; + return `${host.ip}:${port}`; + }; + const renderConnectionIcon = (host: Host) => { + const type = getDefaultConnectionTab(host); + if (type === "terminal" || type === "telnet") { + return ; + } + return ; + }; return (
@@ -330,18 +336,18 @@ function QuickActionsCard({ {pinnedHosts.slice(0, 4).map((host) => ( @@ -423,6 +429,7 @@ function HostStatusCard({ hosts, hostMetrics, onOpenTab, + statusLoading, }: { hosts: Host[]; hostMetrics: Map< @@ -430,6 +437,7 @@ function HostStatusCard({ { cpu: number | null; ram: number | null; disk: number | null } >; onOpenTab: (host: Host, type: TabType) => void; + statusLoading?: boolean; }) { const { t } = useTranslation(); const statusScheme = useStatusColorScheme(); @@ -467,7 +475,7 @@ function HostStatusCard({ >
@@ -506,11 +514,13 @@ function HostStatusCard({
)} - {host.online - ? t("dashboardTab.online") - : t("dashboardTab.offline")} + {statusLoading + ? t("dashboardTab.checking") + : host.online + ? t("dashboardTab.online") + : t("dashboardTab.offline")}
@@ -526,11 +536,13 @@ function RecentActivityCard({ hosts, onOpenTab, onClear, + statusLoading, }: { activity: RecentActivityItem[]; hosts: Host[]; onOpenTab: (host: Host, type: TabType) => void; onClear: () => void; + statusLoading?: boolean; }) { const { t } = useTranslation(); const statusScheme = useStatusColorScheme(); @@ -609,7 +621,7 @@ function RecentActivityCard({ >
@@ -645,28 +657,29 @@ function ServiceLinksCard({ const [label, setLabel] = useState(""); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState(false); + const [addError, setAddError] = useState(""); const [adding, setAdding] = useState(false); const handleAdd = async () => { - let valid = true; - try { - const parsed = new URL(url); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - valid = false; - } - } catch { - valid = false; - } - if (!valid) { + const normalizedUrl = normalizeServiceLinkUrl(url); + if (!isValidServiceLinkUrl(normalizedUrl)) { setUrlError(true); + setAddError(""); return; } setUrlError(false); + setAddError(""); setAdding(true); try { - await onAdd(label.trim(), url.trim()); + await onAdd(label.trim(), normalizedUrl); setLabel(""); setUrl(""); + } catch (error) { + setAddError( + error instanceof Error + ? error.message + : t("dashboardTab.serviceLinksAddFailed"), + ); } finally { setAdding(false); } @@ -728,6 +741,7 @@ function ServiceLinksCard({ onChange={(e) => { setUrl(e.target.value); setUrlError(false); + setAddError(""); }} placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")} className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`} @@ -746,6 +760,11 @@ function ServiceLinksCard({ {t("dashboardTab.serviceLinksInvalidUrl")}
)} + {addError && ( +
+ {addError} +
+ )} ); } @@ -777,6 +796,7 @@ function CardItem({ serviceLinks, onAddServiceLink, onDeleteServiceLink, + statusLoading, }: { slot: CardSlot; editMode: boolean; @@ -785,7 +805,7 @@ function CardItem({ onDrop: () => void; onDragOver: (e: React.DragEvent) => void; onRemove: () => void; - onHeightChange: (id: DashboardCardId, h: number) => void; + onHeightChange: (key: string, h: number) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; @@ -805,6 +825,7 @@ function CardItem({ serviceLinks: ServiceLink[]; onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; + statusLoading?: boolean; }) { const cardRef = useRef(null); @@ -815,7 +836,7 @@ function CardItem({ const startY = e.clientY; const startH = cardRef.current?.getBoundingClientRect().height ?? 100; const onMove = (ev: MouseEvent) => { - onHeightChange(slot.id, Math.max(50, startH + (ev.clientY - startY))); + onHeightChange(slot.key, Math.max(50, startH + (ev.clientY - startY))); }; const onUp = () => { window.removeEventListener("mousemove", onMove); @@ -824,7 +845,7 @@ function CardItem({ window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }, - [slot.id, onHeightChange], + [slot.key, onHeightChange], ); const isFlex = slot.height === null; @@ -886,6 +907,7 @@ function CardItem({ hostMetrics={hostMetrics} onOpenTab={onOpenTab} isAdmin={isAdmin} + statusLoading={statusLoading} /> )} {slot.id === "recent_activity" && ( @@ -894,6 +916,7 @@ function CardItem({ hosts={hosts} onOpenTab={onOpenTab} onClear={onClearActivity} + statusLoading={statusLoading} /> )} {slot.id === "network_graph" && ( @@ -909,6 +932,11 @@ function CardItem({ onDelete={onDeleteServiceLink} /> )} + {slot.id === "homepage_preview" && ( + onOpenSingletonTab("homepage")} + /> + )}
{editMode && !isFlex && (
void; onDrop: (targetPanel: PanelId, targetOrder: number) => void; onDragOver: (e: React.DragEvent) => void; - onRemove: (id: DashboardCardId) => void; + onRemove: (key: string) => void; onAdd: (id: DashboardCardId, panel: PanelId) => void; - onHeightChange: (id: DashboardCardId, h: number) => void; + onHeightChange: (key: string, h: number) => void; onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void; onOpenTab: (host: Host, type: TabType) => void; hosts: Host[]; @@ -1022,6 +1050,7 @@ type PanelColumnProps = { serviceLinks: ServiceLink[]; onAddServiceLink: (label: string, url: string) => Promise; onDeleteServiceLink: (id: number) => Promise; + statusLoading: boolean; }; function PanelColumn({ @@ -1052,6 +1081,7 @@ function PanelColumn({ serviceLinks, onAddServiceLink, onDeleteServiceLink, + statusLoading, }: PanelColumnProps) { const { t } = useTranslation(); const sorted = [...slots].sort((a, b) => a.order - b.order); @@ -1068,7 +1098,7 @@ function PanelColumn({ /> {sorted.map((slot, idx) => (
{idx > 0 && ( @@ -1085,11 +1115,11 @@ function PanelColumn({ onDragStart(slot)} onDrop={() => onDrop(slot.panel, slot.order)} onDragOver={onDragOver} - onRemove={() => onRemove(slot.id)} + onRemove={() => onRemove(slot.key)} onHeightChange={onHeightChange} onOpenSingletonTab={onOpenSingletonTab} onOpenTab={onOpenTab} @@ -1107,6 +1137,7 @@ function PanelColumn({ serviceLinks={serviceLinks} onAddServiceLink={onAddServiceLink} onDeleteServiceLink={onDeleteServiceLink} + statusLoading={statusLoading} />
))} @@ -1162,17 +1193,52 @@ export function DashboardTab({ onOpenTab: (host: Host, type: TabType) => void; }) { const { t, i18n } = useTranslation(); + const { initialLoadComplete } = useServerStatus(); + const statusLoading = !initialLoadComplete; const [slots, setSlots] = useState(() => { try { const saved = localStorage.getItem("dashboardTab.slots"); - if (saved) return JSON.parse(saved) as CardSlot[]; + if (saved) { + const parsed = JSON.parse(saved) as CardSlot[]; + return parsed.map((s, i) => ({ key: s.key ?? `${s.id}_${i}`, ...s })); + } } catch { /* ignore */ } return DEFAULT_SLOTS; }); + const [homepageLinkCopied, setHomepageLinkCopied] = useState(false); + + const handleCopyHomepageLink = () => { + navigator.clipboard + .writeText(`${window.location.origin}?view=homepage`) + .catch(() => {}); + setHomepageLinkCopied(true); + setTimeout(() => setHomepageLinkCopied(false), 1500); + }; + + const [dashboardView, setDashboardView] = useState<"dashboard" | "homepage">( + () => { + try { + return (localStorage.getItem("dashboardView") ?? "dashboard") as + | "dashboard" + | "homepage"; + } catch { + return "dashboard"; + } + }, + ); + + useEffect(() => { + try { + localStorage.setItem("dashboardView", dashboardView); + } catch { + /* ignore */ + } + }, [dashboardView]); + const [editMode, setEditMode] = useState(false); const [dragState, setDragState] = useState(null); @@ -1401,6 +1467,7 @@ export function DashboardTab({ recent_activity: t("dashboard.recentActivity"), network_graph: t("dashboard.networkGraph"), service_links: t("dashboard.serviceLinks"), + homepage_preview: t("dashboard.homepagePreview"), }; const onColumnDividerMouseDown = useCallback( @@ -1430,6 +1497,7 @@ export function DashboardTab({ const handleDragStart = (slot: CardSlot) => setDragState({ + key: slot.key, id: slot.id, sourcePanel: slot.panel, sourceOrder: slot.order, @@ -1438,7 +1506,7 @@ export function DashboardTab({ const handleDrop = (targetPanel: PanelId, targetOrder: number) => { if (!dragState) return; setSlots((prev) => { - const without = prev.filter((s) => s.id !== dragState.id); + const without = prev.filter((s) => s.key !== dragState.key); const panelSlots = without .filter((s) => s.panel === targetPanel) .sort((a, b) => a.order - b.order); @@ -1448,10 +1516,11 @@ export function DashboardTab({ const newPanelSlots = [ ...panelSlots.slice(0, insertAt), { + key: dragState.key, id: dragState.id, panel: targetPanel, order: 0, - height: prev.find((s) => s.id === dragState.id)?.height ?? null, + height: prev.find((s) => s.key === dragState.key)?.height ?? null, }, ...panelSlots.slice(insertAt), ].map((s, i) => ({ ...s, order: i })); @@ -1459,8 +1528,8 @@ export function DashboardTab({ }); setDragState(null); }; - const handleRemove = (id: DashboardCardId) => - setSlots((prev) => prev.filter((s) => s.id !== id)); + const handleRemove = (key: string) => + setSlots((prev) => prev.filter((s) => s.key !== key)); const handleAdd = (id: DashboardCardId, panel: PanelId) => { setSlots((prev) => { const panelSlots = prev.filter((s) => s.panel === panel); @@ -1476,12 +1545,16 @@ export function DashboardTab({ : id === "service_links" ? 200 : 150; - return [...prev, { id, panel, order: maxOrder, height: defaultHeight }]; + const key = `${id}_${Date.now()}`; + return [ + ...prev, + { key, id, panel, order: maxOrder, height: defaultHeight }, + ]; }); }; - const handleHeightChange = (id: DashboardCardId, h: number) => + const handleHeightChange = (key: string, h: number) => setSlots((prev) => - prev.map((s) => (s.id === id ? { ...s, height: h } : s)), + prev.map((s) => (s.key === key ? { ...s, height: h } : s)), ); const handleReset = () => { setSlots(DEFAULT_SLOTS); @@ -1513,6 +1586,7 @@ export function DashboardTab({ serviceLinks, onAddServiceLink: handleAddServiceLink, onDeleteServiceLink: handleDeleteServiceLink, + statusLoading, }; const isMobile = useIsMobile(); @@ -1637,6 +1711,7 @@ export function DashboardTab({ hostMetrics={hostMetrics} onOpenTab={onOpenTab} isAdmin={isAdmin} + statusLoading={statusLoading} /> )} {slot.id === "recent_activity" && ( @@ -1645,6 +1720,7 @@ export function DashboardTab({ hosts={hosts} onOpenTab={onOpenTab} onClear={handleClearActivity} + statusLoading={statusLoading} /> )} {slot.id === "network_graph" && ( @@ -1670,14 +1746,29 @@ export function DashboardTab({ return (
-
-

- {t("dashboard.title")} -

-

{todayLabel}

+
+
+ + +
+ {dashboardView === "dashboard" && ( +

+ {todayLabel} +

+ )}
-
+
{t("dashboardTab.commandPalette")} @@ -1757,95 +1848,143 @@ export function DashboardTab({ {t("dashboard.donate")} - - {editMode ? ( + {dashboardView === "dashboard" && ( <> - - + + {editMode ? ( + <> + + + + ) : ( + + )} - ) : ( - )}
- {editMode && ( -
- - - {t("dashboardTab.editModeInstructions")} - -
- )} - -
-
- -
- - {(hasSide || editMode) && - (editMode ? ( - - ) : ( -
- ))} - - {(hasSide || editMode) && ( -
- + {dashboardView === "homepage" ? ( +
+
+ + {t("nav.homepage")} + +
+ + +
- )} -
+
+ +
+
+ ) : ( + <> + {editMode && ( +
+ + + {t("dashboardTab.editModeInstructions")} + +
+ )} + +
+
+ +
+ + {(hasSide || editMode) && + (editMode ? ( + + ) : ( +
+ ))} + + {(hasSide || editMode) && ( +
+ +
+ )} +
+ + )}
); } diff --git a/src/ui/dashboard/cards/HomepagePreviewCard.tsx b/src/ui/dashboard/cards/HomepagePreviewCard.tsx new file mode 100644 index 00000000..d7984b6d --- /dev/null +++ b/src/ui/dashboard/cards/HomepagePreviewCard.tsx @@ -0,0 +1,36 @@ +import { Card } from "@/components/card"; +import { useTranslation } from "react-i18next"; +import { LayoutGrid } from "lucide-react"; +import { HomepageCanvas } from "@/features/homepage/HomepageCanvas"; + +interface HomepagePreviewCardProps { + onOpenFullscreen: () => void; +} + +export function HomepagePreviewCard({ + onOpenFullscreen, +}: HomepagePreviewCardProps) { + const { t } = useTranslation(); + return ( + + {/* Card header */} +
+ + + {t("homepage.previewTitle")} + + +
+ + {/* Live pannable preview — no pointer-events blocking so panning works */} +
+ +
+
+ ); +} diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index 4cec13a3..74fb2ce0 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -554,7 +554,7 @@ export function NetworkGraphCard({ const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false })); - const fireOpen = (hostId: string, type: string) => { + const fireOpen = (hostId: string, type?: string) => { window.dispatchEvent( new CustomEvent("termix:open-tab", { detail: { hostId, type } }), ); @@ -571,7 +571,7 @@ export function NetworkGraphCard({ setShowNodeDetail(true); } } else if (action === "connect") { - fireOpen(targetId, "terminal"); + fireOpen(targetId); } else if (action === "move") { setSelectedNodeId(targetId); const node = cyRef.current.$id(targetId); diff --git a/src/ui/features/file-manager/FileManager.tsx b/src/ui/features/file-manager/FileManager.tsx index 11d5e434..70ff98e3 100644 --- a/src/ui/features/file-manager/FileManager.tsx +++ b/src/ui/features/file-manager/FileManager.tsx @@ -79,11 +79,14 @@ import type { } from "./file-manager-types.ts"; import { formatFileSize } from "./file-manager-utils.ts"; +const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024; + function FileManagerContent({ initialHost, initialFilePath, initialPath, onClose, + onOpenTerminalTab, }: FileManagerProps) { const { openWindow } = useWindowManager(); const { t } = useTranslation(); @@ -165,6 +168,8 @@ function FileManagerContent({ const [clipboard, setClipboard] = useState<{ files: FileItem[]; operation: "copy" | "cut"; + sourceHostId: number | null; + sourceSessionId: string | null; } | null>(null); interface UndoAction { @@ -797,6 +802,23 @@ function FileManagerContent({ return () => window.removeEventListener("file-manager:refresh", handler); }, [currentHost?.id, handleRefreshDirectory]); + useEffect(() => { + const handler = (event: Event) => { + const detail = ( + event as CustomEvent<{ + files: FileItem[]; + operation: "copy" | "cut"; + sourceHostId: number | null; + sourceSessionId: string | null; + }> + ).detail; + if (!detail) return; + setClipboard(detail); + }; + window.addEventListener("file-manager:clipboard", handler); + return () => window.removeEventListener("file-manager:clipboard", handler); + }, []); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement; @@ -844,12 +866,15 @@ function FileManagerContent({ files.push({ file, relativePath: path }); } else if (entry.isDirectory) { const reader = (entry as FileSystemDirectoryEntry).createReader(); - const dirEntries = await new Promise( - (resolve, reject) => reader.readEntries(resolve, reject), - ); - for (const child of dirEntries) { - await readEntry(child, `${path}/${child.name}`); - } + let batch: FileSystemEntry[]; + do { + batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject), + ); + for (const child of batch) { + await readEntry(child, `${path}/${child.name}`); + } + } while (batch.length > 0); } } @@ -947,6 +972,22 @@ function FileManagerContent({ { duration: Infinity }, ); + const updateProgress = (p: { + chunkIndex: number; + totalChunks: number; + bytesSent: number; + totalBytes: number; + }) => { + const percent = Math.min( + 100, + Math.round((p.bytesSent / p.totalBytes) * 100), + ); + toast.loading( + `Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`, + { id: progressToast, duration: Infinity }, + ); + }; + try { await ensureSSHConnection(); @@ -957,6 +998,7 @@ function FileManagerContent({ file, currentHost?.id, undefined, + updateProgress, ); toast.dismiss(progressToast); @@ -1281,55 +1323,81 @@ function FileManagerContent({ } }; + function openFileWindow(file: FileItem, sessionId: string) { + const windowCount = Date.now() % 10; + const baseOffsetX = 120 + windowCount * 30; + const baseOffsetY = 120 + windowCount * 30; + + const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100); + const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100); + + const offsetX = Math.min(baseOffsetX, maxOffsetX); + const offsetY = Math.min(baseOffsetY, maxOffsetY); + + const createWindowComponent = (windowId: string) => ( + + ); + + openWindow({ + title: file.name, + x: offsetX, + y: offsetY, + width: 800, + height: 600, + isMaximized: false, + isMinimized: false, + component: createWindowComponent, + }); + } + + async function confirmLargeFileOpen(file: FileItem) { + if (!file.size || file.size <= LARGE_FILE_WARNING_SIZE) return true; + + return confirmWithToast( + { + title: t("fileManager.largeFileWarning"), + description: t("fileManager.largeFileWarningDesc", { + size: formatFileSize(file.size), + }), + confirmText: t("fileManager.confirm"), + cancelText: t("common.cancel"), + }, + undefined, + "default", + t("common.cancel"), + { duration: 12000 }, + ); + } + async function handleFileOpen(file: FileItem) { if (file.type === "directory") { if (sshSessionId) setIsLoading(true); setCurrentPath(file.path); - } else if (file.type === "link") { - await handleSymlinkClick(file); - } else { - if (!sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - await recordRecentFile(file); - - const windowCount = Date.now() % 10; - const baseOffsetX = 120 + windowCount * 30; - const baseOffsetY = 120 + windowCount * 30; - - const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100); - const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100); - - const offsetX = Math.min(baseOffsetX, maxOffsetX); - const offsetY = Math.min(baseOffsetY, maxOffsetY); - - const windowTitle = file.name; - - const createWindowComponent = (windowId: string) => ( - - ); - - openWindow({ - title: windowTitle, - x: offsetX, - y: offsetY, - width: 800, - height: 600, - isMaximized: false, - isMinimized: false, - component: createWindowComponent, - }); + return; } + + if (file.type === "link") { + await handleSymlinkClick(file); + return; + } + + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + if (!(await confirmLargeFileOpen(file))) return; + + await recordRecentFile(file); + openFileWindow(file, sshSessionId); } function handleContextMenu(event: React.MouseEvent, file?: FileItem) { @@ -1365,14 +1433,32 @@ function FileManagerContent({ } function handleCopyFiles(files: FileItem[]) { - setClipboard({ files, operation: "copy" }); + const entry = { + files, + operation: "copy" as const, + sourceHostId: currentHost?.id ?? null, + sourceSessionId: sshSessionId, + }; + setClipboard(entry); + window.dispatchEvent( + new CustomEvent("file-manager:clipboard", { detail: entry }), + ); toast.success( t("fileManager.filesCopiedToClipboard", { count: files.length }), ); } function handleCutFiles(files: FileItem[]) { - setClipboard({ files, operation: "cut" }); + const entry = { + files, + operation: "cut" as const, + sourceHostId: currentHost?.id ?? null, + sourceSessionId: sshSessionId, + }; + setClipboard(entry); + window.dispatchEvent( + new CustomEvent("file-manager:clipboard", { detail: entry }), + ); toast.success( t("fileManager.filesCutToClipboard", { count: files.length }), ); @@ -1413,9 +1499,64 @@ function FileManagerContent({ }); } + async function handleCrossHostPaste() { + if (!clipboard || !sshSessionId || !currentHost) return; + + const { files, operation, sourceSessionId } = clipboard; + if (!sourceSessionId) return; + + const sourcePaths = files.map((f) => f.path); + + try { + const { transferId } = await transferToHost( + sourceSessionId, + sourcePaths, + sshSessionId, + currentPath, + operation === "cut", + "auto", + 2, + ); + + const monitorHandle = beginTransferProgressMonitoring(transferId, t, { + formatTransferMetrics, + }); + if (!monitorHandle) return; + + const finalStatus = await monitorHandle.waitForCompletion; + + if ( + finalStatus.status !== "success" && + finalStatus.status !== "partial" + ) { + return; + } + + if (operation === "cut") { + setClipboard(null); + } + + handleRefreshDirectory(); + clearSelection(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handlePasteFiles() { if (!clipboard || !sshSessionId) return; + if ( + clipboard.sourceHostId !== null && + clipboard.sourceHostId !== currentHost?.id + ) { + await handleCrossHostPaste(); + return; + } + try { await ensureSSHConnection(); @@ -2455,6 +2596,7 @@ function FileManagerContent({ initialPath={path} initialX={offsetX} initialY={offsetY} + onPromoteToTab={onOpenTerminalTab} /> ); @@ -2501,6 +2643,7 @@ function FileManagerContent({ initialX={offsetX} initialY={offsetY} executeCommand={executeCmd} + onPromoteToTab={onOpenTerminalTab} /> ); @@ -2676,7 +2819,7 @@ function FileManagerContent({ }); break; case "modified": - result = (a.modified || "").localeCompare(b.modified || ""); + result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0); break; case "size": result = (a.size || 0) - (b.size || 0); @@ -2961,6 +3104,7 @@ function FileManagerInner({ initialFilePath, initialPath, onClose, + onOpenTerminalTab, }: FileManagerProps) { return ( @@ -2969,6 +3113,7 @@ function FileManagerInner({ initialFilePath={initialFilePath} initialPath={initialPath} onClose={onClose} + onOpenTerminalTab={onOpenTerminalTab} /> ); diff --git a/src/ui/features/file-manager/components/DraggableWindow.tsx b/src/ui/features/file-manager/components/DraggableWindow.tsx index a5087305..c835f784 100644 --- a/src/ui/features/file-manager/components/DraggableWindow.tsx +++ b/src/ui/features/file-manager/components/DraggableWindow.tsx @@ -20,6 +20,7 @@ interface DraggableWindowProps { zIndex?: number; onFocus?: () => void; targetSize?: { width: number; height: number }; + titleActions?: React.ReactNode; } export function DraggableWindow({ @@ -39,6 +40,7 @@ export function DraggableWindow({ zIndex = 1000, onFocus, targetSize, + titleActions, }: DraggableWindowProps) { const { t } = useTranslation(); const [position, setPosition] = useState({ x: initialX, y: initialY }); @@ -284,12 +286,14 @@ export function DraggableWindow({ {t("fileManager.editor")}
- + {title}
+ {titleActions} + {onMinimize && ( )} + {isEditable && onOpenExternal && ( +
+ + {onChooseExternalEditor && ( + + )} +
+ )}
diff --git a/src/ui/features/file-manager/components/FileWindow.tsx b/src/ui/features/file-manager/components/FileWindow.tsx index 00a2c07c..20ee2b9e 100644 --- a/src/ui/features/file-manager/components/FileWindow.tsx +++ b/src/ui/features/file-manager/components/FileWindow.tsx @@ -87,10 +87,26 @@ export function FileWindow({ const [mediaDimensions, setMediaDimensions] = useState< { width: number; height: number } | undefined >(); + const [externalEditorPath, setExternalEditorPath] = useState(""); const autoSaveTimerRef = useRef(null); + const externalEditorRef = useRef<{ + editId: string; + unsubscribe?: () => void; + } | null>(null); const currentWindow = windows.find((w) => w.id === windowId); + useEffect(() => { + if (!window.electronAPI?.isElectron) return; + + window.electronAPI + .getSetting?.("fileManager.externalEditorPath") + .then((value) => { + if (typeof value === "string") setExternalEditorPath(value); + }) + .catch(() => {}); + }, []); + const ensureSSHConnection = async () => { try { const status = await getSSHStatus(sshSessionId); @@ -328,6 +344,93 @@ export function FileWindow({ } }; + const closeExternalEditor = async () => { + const session = externalEditorRef.current; + if (!session) return; + + session.unsubscribe?.(); + externalEditorRef.current = null; + + try { + await window.electronAPI?.closeExternalEditor?.(session.editId); + } catch (error) { + console.error("Failed to close external editor session:", error); + } + }; + + const handleOpenExternalEditor = async () => { + if (!window.electronAPI?.isElectron) { + toast.error(t("fileManager.externalEditorDesktopOnly")); + return; + } + + try { + await closeExternalEditor(); + const result = await window.electronAPI.openExternalEditor({ + fileName: file.name, + content: pendingContent || content, + encoding: "utf8", + editorPath: externalEditorPath || null, + }); + + if (!result.success || !result.editId) { + throw new Error(result.error || "Failed to open external editor"); + } + + const unsubscribe = window.electronAPI.onExternalEditorSaved?.( + (payload) => { + if (payload.editId !== result.editId) return; + void handleSave(payload.content); + }, + ); + + externalEditorRef.current = { + editId: result.editId, + unsubscribe, + }; + toast.success(t("fileManager.externalEditorOpened")); + } catch (error: unknown) { + console.error("Failed to open external editor:", error); + const err = error as { message?: string }; + toast.error( + `${t("fileManager.failedToOpenExternalEditor")}: ${ + err.message || t("fileManager.unknownError") + }`, + ); + } + }; + + const handleChooseExternalEditor = async () => { + if (!window.electronAPI?.isElectron) { + toast.error(t("fileManager.externalEditorDesktopOnly")); + return; + } + + try { + const result = await window.electronAPI.showOpenDialog({ + title: t("fileManager.chooseExternalEditor"), + properties: ["openFile"], + }); + const editorPath = result.filePaths?.[0]; + if (result.canceled || !editorPath) return; + + await window.electronAPI.setSetting?.( + "fileManager.externalEditorPath", + editorPath, + ); + setExternalEditorPath(editorPath); + toast.success(t("fileManager.externalEditorSelected")); + } catch (error: unknown) { + console.error("Failed to select external editor:", error); + const err = error as { message?: string }; + toast.error( + `${t("fileManager.failedToSelectExternalEditor")}: ${ + err.message || t("fileManager.unknownError") + }`, + ); + } + }; + const handleContentChange = (newContent: string) => { setPendingContent(newContent); @@ -354,6 +457,7 @@ export function FileWindow({ if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); } + void closeExternalEditor(); }; }, []); @@ -405,6 +509,7 @@ export function FileWindow({ }; const handleClose = () => { + void closeExternalEditor(); closeWindow(windowId); }; @@ -454,6 +559,16 @@ export function FileWindow({ onContentChange={handleContentChange} onSave={(newContent) => handleSave(newContent)} onDownload={handleDownload} + onOpenExternal={ + window.electronAPI?.isElectron && isEditable + ? handleOpenExternalEditor + : undefined + } + onChooseExternalEditor={ + window.electronAPI?.isElectron && isEditable + ? handleChooseExternalEditor + : undefined + } onMediaDimensionsChange={handleMediaDimensionsChange} /> diff --git a/src/ui/features/file-manager/components/PermissionsDialog.tsx b/src/ui/features/file-manager/components/PermissionsDialog.tsx index 4c8663f9..4a9ef386 100644 --- a/src/ui/features/file-manager/components/PermissionsDialog.tsx +++ b/src/ui/features/file-manager/components/PermissionsDialog.tsx @@ -174,7 +174,7 @@ export function PermissionsDialog({ {t("fileManager.changePermissions")} - + {file.path} diff --git a/src/ui/features/file-manager/components/TerminalWindow.tsx b/src/ui/features/file-manager/components/TerminalWindow.tsx index a38f2f8e..5d383b2e 100644 --- a/src/ui/features/file-manager/components/TerminalWindow.tsx +++ b/src/ui/features/file-manager/components/TerminalWindow.tsx @@ -9,6 +9,7 @@ import { useWindowManager } from "./WindowManager.tsx"; import { useTranslation } from "react-i18next"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; import type { SSHHost } from "@/types/index.ts"; +import { ExternalLink } from "lucide-react"; interface TerminalWindowProps { windowId: string; @@ -17,6 +18,7 @@ interface TerminalWindowProps { initialX?: number; initialY?: number; executeCommand?: string; + onPromoteToTab?: (path?: string) => void; } export function TerminalWindow({ @@ -26,6 +28,7 @@ export function TerminalWindow({ initialX = 200, initialY = 150, executeCommand, + onPromoteToTab, }: TerminalWindowProps) { const { t } = useTranslation(); const { closeWindow, maximizeWindow, focusWindow, windows } = @@ -78,6 +81,11 @@ export function TerminalWindow({ }, 100); }; + const handlePromoteToTab = () => { + onPromoteToTab?.(initialPath); + closeWindow(windowId); + }; + const terminalTitle = executeCommand ? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand }) : initialPath @@ -103,6 +111,20 @@ export function TerminalWindow({ onResize={handleResize} isMaximized={currentWindow.isMaximized} zIndex={currentWindow.zIndex} + titleActions={ + onPromoteToTab ? ( + + ) : null + } > void; + onOpenTerminalTab?: (path?: string) => void; } export type ConnectionLogPayload = Omit; diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 83651b61..f4632bf1 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { + useState, + useEffect, + useRef, + useCallback, + useImperativeHandle, +} from "react"; import { GuacamoleDisplay, type GuacamoleDisplayHandle, @@ -22,67 +28,71 @@ interface GuacamoleAppProps { protocol?: "rdp" | "vnc" | "telnet"; } -const GuacamoleApp: React.FC = ({ - hostId, - tabId, - protocol, -}) => { - const { t } = useTranslation(); - const [hostConfig, setHostConfig] = useState(null); - const [loading, setLoading] = useState(true); +export interface GuacamoleAppHandle { + disconnect: () => void; + isConnected: () => boolean; +} - useEffect(() => { - if (!hostId) { - setLoading(false); - return; +const GuacamoleApp = React.forwardRef( + function GuacamoleApp({ hostId, tabId, protocol }, ref) { + const { t } = useTranslation(); + const [hostConfig, setHostConfig] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!hostId) { + setLoading(false); + return; + } + getSSHHosts() + .then((hosts) => { + const host = hosts.find((h) => h.id === parseInt(hostId, 10)); + setHostConfig(host ?? null); + }) + .catch(() => setHostConfig(null)) + .finally(() => setLoading(false)); + }, [hostId]); + + if (loading) { + return ( +
+ +
+ ); } - getSSHHosts() - .then((hosts) => { - const host = hosts.find((h) => h.id === parseInt(hostId, 10)); - setHostConfig(host ?? null); - }) - .catch(() => setHostConfig(null)) - .finally(() => setLoading(false)); - }, [hostId]); - if (loading) { - return ( -
- -
- ); - } - - if (!hostConfig || !hostId) { - return ( -
- - - {t("guacamole.hostNotFound")} - -
- ); - } + + + {t("guacamole.hostNotFound")} + +
+ ); + } - return ( - - ); -}; + return ( + + ); + }, +); interface GuacamoleAppInnerProps { hostId: number; @@ -92,13 +102,13 @@ interface GuacamoleAppInnerProps { protocol?: "rdp" | "vnc" | "telnet"; } -const GuacamoleAppInner: React.FC = ({ - hostId, - hostConfig, - hostName, - tabId, - protocol, -}) => { +const GuacamoleAppInner = React.forwardRef< + GuacamoleAppHandle, + GuacamoleAppInnerProps +>(function GuacamoleAppInner( + { hostId, hostConfig, hostName, tabId, protocol }, + ref, +) { const { t } = useTranslation(); const [token, setToken] = useState(null); const [error, setError] = useState(null); @@ -106,6 +116,11 @@ const GuacamoleAppInner: React.FC = ({ const [retryCount, setRetryCount] = useState(0); const displayRef = useRef(null); + useImperativeHandle(ref, () => ({ + disconnect: () => displayRef.current?.disconnect(), + isConnected: () => displayRef.current?.isConnected() === true, + })); + useEffect(() => { setToken(null); setError(null); @@ -127,7 +142,7 @@ const GuacamoleAppInner: React.FC = ({ } }) .catch((err) => setError(err?.message || t("guacamole.failedToConnect"))); - }, [hostId, protocol, retryCount, t]); + }, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]); const handleReconnect = useCallback(() => { setConnectionError(null); @@ -242,6 +257,6 @@ const GuacamoleAppInner: React.FC = ({
); -}; +}); export default GuacamoleApp; diff --git a/src/ui/features/guacamole/GuacamoleDisplay.test.ts b/src/ui/features/guacamole/GuacamoleDisplay.test.ts new file mode 100644 index 00000000..17e07de8 --- /dev/null +++ b/src/ui/features/guacamole/GuacamoleDisplay.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.js"; + +const httpsLocation = { + protocol: "https:", + host: "termix.example.com", +} as Location; + +describe("buildGuacamoleWebSocketBaseUrl", () => { + it("uses the same origin guacamole websocket path in production web builds", () => { + expect( + buildGuacamoleWebSocketBaseUrl({ + isDev: false, + isElectronApp: false, + isEmbeddedApp: false, + basePath: "", + location: httpsLocation, + }), + ).toBe("wss://termix.example.com/guacamole/websocket/"); + }); + + it("preserves the runtime base path for proxied web deployments", () => { + expect( + buildGuacamoleWebSocketBaseUrl({ + isDev: false, + isElectronApp: false, + isEmbeddedApp: false, + basePath: "/termix", + location: httpsLocation, + }), + ).toBe("wss://termix.example.com/termix/guacamole/websocket/"); + }); + + it("keeps direct local websocket access for development and embedded electron", () => { + expect( + buildGuacamoleWebSocketBaseUrl({ + isDev: true, + isElectronApp: false, + isEmbeddedApp: false, + basePath: "/termix", + location: httpsLocation, + }), + ).toBe("ws://localhost:30008"); + + expect( + buildGuacamoleWebSocketBaseUrl({ + isDev: false, + isElectronApp: true, + isEmbeddedApp: true, + basePath: "/termix", + location: httpsLocation, + }), + ).toBe("ws://127.0.0.1:30008"); + }); + + it("uses the configured remote server URL for electron remote mode", () => { + expect( + buildGuacamoleWebSocketBaseUrl({ + isDev: false, + isElectronApp: true, + isEmbeddedApp: false, + configuredServerUrl: "https://termix.example.com/termix/", + basePath: "", + location: httpsLocation, + }), + ).toBe("wss://termix.example.com/termix/guacamole/websocket/"); + }); +}); diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx index 4747e67c..5643d92b 100644 --- a/src/ui/features/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx @@ -10,6 +10,8 @@ import Guacamole from "guacamole-common-js"; import { useTranslation } from "react-i18next"; import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; +import { getBasePath } from "@/lib/base-path.ts"; +import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; @@ -30,6 +32,7 @@ export interface GuacamoleConnectionConfig { export interface GuacamoleDisplayHandle { disconnect: () => void; + isConnected: () => boolean; sendKey: (keysym: number, pressed: boolean) => void; sendMouse: (x: number, y: number, buttonMask: number) => void; setClipboard: (data: string) => void; @@ -64,15 +67,28 @@ export const GuacamoleDisplay = forwardRef< const windowFocusedRef = useRef( typeof document === "undefined" ? true : document.hasFocus(), ); + const hasInitiatedRef = useRef(false); + const isMountedRef = useRef(false); + const isConnectingRef = useRef(false); const [isReady, setIsReady] = useState(false); const [hasError, setHasError] = useState(false); + const disconnectClient = useCallback(() => { + const client = clientRef.current; + clientRef.current = null; + isConnectingRef.current = false; + if (!client) return; + + try { + client.disconnect(); + } catch (error) { + console.warn("Failed to disconnect Guacamole client", error); + } + }, []); + useImperativeHandle(ref, () => ({ - disconnect: () => { - if (clientRef.current) { - clientRef.current.disconnect(); - } - }, + disconnect: disconnectClient, + isConnected: () => isReady && !hasError, sendKey: (keysym: number, pressed: boolean) => { if (clientRef.current) { clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym); @@ -138,29 +154,15 @@ export const GuacamoleDisplay = forwardRef< const width = connectionConfig.width ?? containerWidth ?? 1280; const height = connectionConfig.height ?? containerHeight ?? 720; - const wsBase = isDev - ? `ws://localhost:30008` - : isElectron() - ? (() => { - const configuredUrl = ( - window as { configuredServerUrl?: string } - ).configuredServerUrl; - - // Embedded mode or no configured remote server: connect directly - // to the local guacamole websocket service. - if (isEmbeddedMode() || !configuredUrl) { - return "ws://127.0.0.1:30008"; - } - - const wsProtocol = configuredUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = configuredUrl - .replace(/^https?:\/\//, "") - .replace(/\/$/, ""); - return `${wsProtocol}${wsHost}/guacamole/websocket/`; - })() - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`; + const wsBase = buildGuacamoleWebSocketBaseUrl({ + isDev, + isElectronApp: isElectron(), + isEmbeddedApp: isEmbeddedMode(), + configuredServerUrl: (window as { configuredServerUrl?: string }) + .configuredServerUrl, + basePath: getBasePath(), + location: window.location, + }); const params = new URLSearchParams({ token, @@ -274,6 +276,10 @@ export const GuacamoleDisplay = forwardRef< await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); + if (!isMountedRef.current) { + isConnectingRef.current = false; + return; + } // The tab's DOM node can still be display:none (and report 0x0) when this // tab is restored in the background. Measuring then would force the @@ -294,6 +300,10 @@ export const GuacamoleDisplay = forwardRef< await new Promise((resolve) => requestAnimationFrame(() => resolve()), ); + if (!isMountedRef.current) { + isConnectingRef.current = false; + return; + } ({ width: containerWidth, height: containerHeight } = measureContainer()); } @@ -303,6 +313,10 @@ export const GuacamoleDisplay = forwardRef< } const wsUrl = await getWebSocketUrl(containerWidth, containerHeight); + if (!isMountedRef.current) { + isConnectingRef.current = false; + return; + } if (!wsUrl) { isConnectingRef.current = false; return; @@ -325,12 +339,13 @@ export const GuacamoleDisplay = forwardRef< displayElement.style.outline = "none"; display.onresize = () => { + if (!isMountedRef.current) return; rescaleDisplay(true); setIsReady(true); }; const protocol = connectionConfig.protocol ?? connectionConfig.type; - if (protocol === "telnet") { + if (protocol === "telnet" && isMountedRef.current) { setIsReady(true); } @@ -374,6 +389,7 @@ export const GuacamoleDisplay = forwardRef< refreshKeyboardHandlers(); client.onstatechange = (state: number) => { + if (!isMountedRef.current) return; switch (state) { case 0: break; @@ -405,6 +421,7 @@ export const GuacamoleDisplay = forwardRef< }; client.onerror = (error: Guacamole.Status) => { + if (!isMountedRef.current) return; const errorMessage = error.message || t("guacamole.connectionError"); setIsReady(false); setHasError(true); @@ -447,7 +464,17 @@ export const GuacamoleDisplay = forwardRef< stream.sendAck("OK", Guacamole.Status.Code.SUCCESS); }; - client.connect(); + try { + client.connect(); + } catch (error) { + isConnectingRef.current = false; + if (!isMountedRef.current) return; + setIsReady(false); + setHasError(true); + onError?.( + error instanceof Error ? error.message : t("guacamole.connectionError"), + ); + } }, [ getWebSocketUrl, onConnect, @@ -460,10 +487,6 @@ export const GuacamoleDisplay = forwardRef< t, ]); - const hasInitiatedRef = useRef(false); - const isMountedRef = useRef(false); - const isConnectingRef = useRef(false); - useEffect(() => { isMountedRef.current = true; @@ -521,13 +544,10 @@ export const GuacamoleDisplay = forwardRef< if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); } - if (clientRef.current) { - clientRef.current.disconnect(); - clientRef.current = null; - } + disconnectClient(); displayElementRef.current = null; }; - }, []); + }, [disconnectClient]); useEffect(() => { if (!containerRef.current) return; diff --git a/src/ui/features/guacamole/guacamole-websocket-url.ts b/src/ui/features/guacamole/guacamole-websocket-url.ts new file mode 100644 index 00000000..717751c7 --- /dev/null +++ b/src/ui/features/guacamole/guacamole-websocket-url.ts @@ -0,0 +1,31 @@ +export function buildGuacamoleWebSocketBaseUrl({ + isDev, + isElectronApp, + isEmbeddedApp, + configuredServerUrl, + basePath, + location, +}: { + isDev: boolean; + isElectronApp: boolean; + isEmbeddedApp: boolean; + configuredServerUrl?: string; + basePath: string; + location: Pick; +}) { + if (isDev) return "ws://localhost:30008"; + if (isElectronApp) { + if (isEmbeddedApp || !configuredServerUrl) return "ws://127.0.0.1:30008"; + + const wsProtocol = configuredServerUrl.startsWith("https://") + ? "wss://" + : "ws://"; + const wsHost = configuredServerUrl + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + return `${wsProtocol}${wsHost}/guacamole/websocket/`; + } + + const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; + return `${wsProtocol}://${location.host}${basePath}/guacamole/websocket/`; +} diff --git a/src/ui/features/homepage/HomepageApp.tsx b/src/ui/features/homepage/HomepageApp.tsx new file mode 100644 index 00000000..dc6aafb1 --- /dev/null +++ b/src/ui/features/homepage/HomepageApp.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { HomepageCanvas } from "./HomepageCanvas"; + +const HomepageApp: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default HomepageApp; diff --git a/src/ui/features/homepage/HomepageCanvas.tsx b/src/ui/features/homepage/HomepageCanvas.tsx new file mode 100644 index 00000000..2edacd5c --- /dev/null +++ b/src/ui/features/homepage/HomepageCanvas.tsx @@ -0,0 +1,571 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { LayoutGrid } from "lucide-react"; +import type { + CanvasWidget, + ContextMenuState, + WidgetTypeId, +} from "@/types/homepage-types"; +import { GRID_SIZE, MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types"; +import { + getHomepageItems, + getHomepageLayout, + createHomepageItem, + updateHomepageItem, + deleteHomepageItem, + saveHomepageLayout, +} from "@/api/homepage-api"; +import { snapToGrid } from "./canvas/snapToGrid"; +import { screenToCanvas } from "./canvas/canvasGeometry"; +import { useCanvasInput } from "./canvas/useCanvasInput"; +import { useBoxDrag } from "./canvas/useBoxDrag"; +import { useBoxResize } from "./canvas/useBoxResize"; +import { CanvasDotBackground } from "./canvas/CanvasDotBackground"; +import { WidgetShell } from "./widgets/WidgetShell"; +import { AddWidgetMenu } from "./dialogs/AddWidgetMenu"; +import { WidgetEditDialog } from "./dialogs/WidgetEditDialog"; +import { HomepageToolbar } from "./toolbar/HomepageToolbar"; +import { getWidgetType } from "./widgets/WidgetRegistry"; + +// Side-effect imports so widgets register themselves +import "./widgets/ServiceLinkWidget"; +import "./widgets/ClockWidget"; +import "./widgets/NotesWidget"; +import "./widgets/BookmarkListWidget"; +import "./widgets/HostStatusWidget"; +import "./widgets/FolderWidget"; +import "./widgets/WeatherWidget"; +import "./widgets/IframeWidget"; +import "./widgets/RssFeedWidget"; +import "./widgets/MetricsChartWidget"; +import "./widgets/HostGridWidget"; +import "./widgets/AlertFeedWidget"; +import "./widgets/PingStatusWidget"; +import "./widgets/RecentActivityWidget"; +import "./widgets/TermixUptimeWidget"; +import "./widgets/SystemOverviewWidget"; +import "./widgets/SshTerminalWidget"; +import "./widgets/QuickConnectWidget"; +import "./widgets/FileManagerWidget"; +import "./widgets/DockerWidget"; +import "./widgets/TunnelWidget"; +import "./widgets/CalendarWidget"; +import "./widgets/CountdownWidget"; +import "./widgets/SearchBarWidget"; +import "./widgets/TextBannerWidget"; +import "./widgets/ImageWidget"; +import "./widgets/MarkdownNotesWidget"; +import "./widgets/CustomApiWidget"; +import "./widgets/ServiceGridWidget"; +import "./widgets/DashboardLinksWidget"; +import "./widgets/SearchLinksWidget"; +import "./widgets/LinkTreeWidget"; + +const CANVAS_SIZE = 100_000; +const DEFAULT_PAN = { x: CANVAS_SIZE / 2 - 600, y: CANVAS_SIZE / 2 - 400 }; +const DEFAULT_ZOOM = 1.0; + +interface HomepageCanvasProps { + isReadOnly?: boolean; + fitOnLoad?: boolean; + onOpenFullscreen?: () => void; +} + +function nextZOrder(widgets: CanvasWidget[]): number { + const nonFolders = widgets.filter((w) => w.typeId !== "folder"); + return nonFolders.length === 0 + ? 1 + : Math.max(...nonFolders.map((w) => w.zOrder)) + 1; +} + +export function HomepageCanvas({ + isReadOnly, + fitOnLoad, + onOpenFullscreen, +}: HomepageCanvasProps) { + const { t } = useTranslation(); + const containerRef = useRef(null); + const [widgets, setWidgets] = useState([]); + const [pan, setPan] = useState(DEFAULT_PAN); + const [zoom, setZoom] = useState(DEFAULT_ZOOM); + const [isLocked, setIsLocked] = useState( + () => localStorage.getItem("homepage.locked") === "true", + ); + const [draggingId, setDraggingId] = useState(null); + const [resizingId, setResizingId] = useState(null); + const [editingWidget, setEditingWidget] = useState(null); + const [dialogKey, setDialogKey] = useState(0); + const [contextMenu, setContextMenu] = useState({ + visible: false, + screenX: 0, + screenY: 0, + canvasX: 0, + canvasY: 0, + }); + const [containerSize, setContainerSize] = useState({ w: 800, h: 600 }); + const [loading, setLoading] = useState(true); + const saveTimerRef = useRef | null>(null); + + const panRef = useRef(pan); + const zoomRef = useRef(zoom); + const widgetsRef = useRef(widgets); + panRef.current = pan; + zoomRef.current = zoom; + widgetsRef.current = widgets; + + const hasFitRef = useRef(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + setContainerSize({ w: el.offsetWidth, h: el.offsetHeight }); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + let cancelled = false; + Promise.all([getHomepageItems(), getHomepageLayout()]) + .then(([items, layoutRow]) => { + if (cancelled) return; + const layoutEntries = layoutRow?.layout?.entries ?? []; + const merged: CanvasWidget[] = items.map((item) => { + const entry = layoutEntries.find((e) => e.itemId === item.id); + const typeDef = getWidgetType(item.typeId as WidgetTypeId); + return { + id: item.id, + typeId: item.typeId as WidgetTypeId, + title: item.title, + config: JSON.parse(item.config || "{}"), + x: entry?.x ?? snapToGrid(Math.random() * 600 + 100), + y: entry?.y ?? snapToGrid(Math.random() * 400 + 100), + w: entry?.w ?? typeDef?.defaultSize.w ?? GRID_SIZE * 8, + h: entry?.h ?? typeDef?.defaultSize.h ?? GRID_SIZE * 6, + zOrder: entry?.zOrder ?? 0, + }; + }); + setWidgets(merged); + if (layoutRow?.layout?.pan) setPan(layoutRow.layout.pan); + if (layoutRow?.layout?.zoom) setZoom(layoutRow.layout.zoom); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const scheduleSave = useCallback( + (newWidgets?: CanvasWidget[], newPan?: typeof pan, newZoom?: number) => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + const ws = newWidgets ?? widgetsRef.current; + const p = newPan ?? panRef.current; + const z = newZoom ?? zoomRef.current; + saveHomepageLayout({ + entries: ws.map((w) => ({ + itemId: w.id, + x: w.x, + y: w.y, + w: w.w, + h: w.h, + zOrder: w.zOrder, + })), + pan: p, + zoom: z, + }).catch(() => {}); + }, 500); + }, + [], + ); + + useEffect(() => { + if (!fitOnLoad || loading || widgets.length === 0 || hasFitRef.current) + return; + const el = containerRef.current; + if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return; + hasFitRef.current = true; + const vw = el.offsetWidth; + const vh = el.offsetHeight; + const minX = Math.min(...widgets.map((w) => w.x)); + const minY = Math.min(...widgets.map((w) => w.y)); + const maxX = Math.max(...widgets.map((w) => w.x + w.w)); + const maxY = Math.max(...widgets.map((w) => w.y + w.h)); + const contentW = maxX - minX; + const contentH = maxY - minY; + const PADDING = 48; + const fz = Math.min( + MAX_ZOOM, + Math.max( + MIN_ZOOM, + Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH), + ), + ); + setPan({ + x: (vw - contentW * fz) / 2 - minX * fz, + y: (vh - contentH * fz) / 2 - minY * fz, + }); + setZoom(fz); + }, [fitOnLoad, loading, widgets, containerSize]); + + const handlePanChange = useCallback( + (newPan: typeof pan) => { + setPan(newPan); + scheduleSave(undefined, newPan, undefined); + }, + [scheduleSave], + ); + + const handleZoomChange = useCallback( + (newZoom: number, newPan: typeof pan) => { + setZoom(newZoom); + setPan(newPan); + scheduleSave(undefined, newPan, newZoom); + }, + [scheduleSave], + ); + + const canvasInput = useCanvasInput({ + isDraggingWidget: draggingId !== null, + isResizing: resizingId !== null, + isContextMenuVisible: contextMenu.visible, + onPanChange: handlePanChange, + onZoomChange: handleZoomChange, + getPan: () => panRef.current, + getZoom: () => zoomRef.current, + containerRef, + }); + + const handleWidgetMove = useCallback( + (id: number, x: number, y: number) => { + setWidgets((prev) => { + const next = prev.map((w) => (w.id === id ? { ...w, x, y } : w)); + scheduleSave(next); + return next; + }); + }, + [scheduleSave], + ); + + const handleWidgetResize = useCallback( + (id: number, w: number, h: number) => { + setWidgets((prev) => { + const next = prev.map((widget) => + widget.id === id ? { ...widget, w, h } : widget, + ); + scheduleSave(next); + return next; + }); + }, + [scheduleSave], + ); + + const handleDragStart = useCallback( + (id: number) => { + setDraggingId(id); + setWidgets((prev) => { + const widget = prev.find((w) => w.id === id); + if (!widget || widget.typeId === "folder") return prev; + const top = nextZOrder(prev); + const next = prev.map((w) => (w.id === id ? { ...w, zOrder: top } : w)); + scheduleSave(next); + return next; + }); + }, + [scheduleSave], + ); + + const { startDrag } = useBoxDrag({ + widgets, + zoom, + getZoom: () => zoomRef.current, + onWidgetMove: handleWidgetMove, + onDragStart: handleDragStart, + onDragEnd: () => setDraggingId(null), + }); + + const { startResize } = useBoxResize({ + widgets, + getZoom: () => zoomRef.current, + onWidgetResize: handleWidgetResize, + onResizeStart: setResizingId, + onResizeEnd: () => setResizingId(null), + }); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + if (isLocked || isReadOnly) return; + if ((e.target as HTMLElement).closest("[data-widget]")) return; + e.preventDefault(); + const rect = containerRef.current!.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const { x, y } = screenToCanvas(sx, sy, panRef.current, zoomRef.current); + setContextMenu({ + visible: true, + screenX: e.clientX, + screenY: e.clientY, + canvasX: snapToGrid(x), + canvasY: snapToGrid(y), + }); + }, + [isLocked, isReadOnly], + ); + + const handleAddWidget = useCallback( + async (typeId: WidgetTypeId, canvasX: number, canvasY: number) => { + const typeDef = getWidgetType(typeId); + if (!typeDef) return; + try { + const item = await createHomepageItem({ + typeId, + config: typeDef.defaultConfig as Record, + }); + setWidgets((prev) => { + const zOrder = typeId === "folder" ? 0 : nextZOrder(prev); + const newWidget: CanvasWidget = { + id: item.id, + typeId, + title: item.title, + config: typeDef.defaultConfig as Record, + x: canvasX, + y: canvasY, + w: typeDef.defaultSize.w, + h: typeDef.defaultSize.h, + zOrder, + }; + const next = [...prev, newWidget]; + scheduleSave(next); + return next; + }); + } catch {} + }, + [scheduleSave], + ); + + const handleOpenAddMenu = useCallback( + (anchorRect: { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; + }) => { + const w = containerSize.w; + const h = containerSize.h; + const { x, y } = screenToCanvas( + w / 2, + h / 2, + panRef.current, + zoomRef.current, + ); + setContextMenu({ + visible: true, + screenX: anchorRect.left, + screenY: anchorRect.top, + canvasX: snapToGrid(x), + canvasY: snapToGrid(y), + anchorRect, + }); + }, + [containerSize], + ); + + const handleDelete = useCallback( + async (id: number) => { + try { + await deleteHomepageItem(id); + setWidgets((prev) => { + const next = prev.filter((w) => w.id !== id); + scheduleSave(next); + return next; + }); + } catch {} + }, + [scheduleSave], + ); + + const handleEdit = useCallback( + (id: number) => { + const w = widgets.find((x) => x.id === id); + if (w) { + setEditingWidget(w); + setDialogKey((k) => k + 1); + } + }, + [widgets], + ); + + const handleSaveEdit = useCallback( + async ( + id: number, + title: string | null, + config: Record, + ) => { + try { + await updateHomepageItem(id, { title, config }); + setWidgets((prev) => + prev.map((w) => (w.id === id ? { ...w, title, config } : w)), + ); + } catch {} + }, + [], + ); + + const resetView = useCallback(() => { + const ws = widgetsRef.current; + const el = containerRef.current; + const vw = el?.offsetWidth ?? 800; + const vh = el?.offsetHeight ?? 600; + + if (ws.length === 0) { + setPan(DEFAULT_PAN); + setZoom(DEFAULT_ZOOM); + return; + } + + const minX = Math.min(...ws.map((w) => w.x)); + const minY = Math.min(...ws.map((w) => w.y)); + const maxX = Math.max(...ws.map((w) => w.x + w.w)); + const maxY = Math.max(...ws.map((w) => w.y + w.h)); + const contentW = maxX - minX; + const contentH = maxY - minY; + + const PADDING = 48; + const fitZoom = Math.min( + MAX_ZOOM, + Math.max( + MIN_ZOOM, + Math.min((vw - PADDING * 2) / contentW, (vh - PADDING * 2) / contentH), + ), + ); + + // Place the content bbox center at the viewport center + const newPan = { + x: (vw - contentW * fitZoom) / 2 - minX * fitZoom, + y: (vh - contentH * fitZoom) / 2 - minY * fitZoom, + }; + + setZoom(fitZoom); + setPan(newPan); + scheduleSave(undefined, newPan, fitZoom); + }, [scheduleSave]); + + // Folders always render first (lowest z), non-folders sorted by zOrder ascending + const sortedWidgets = [...widgets].sort((a, b) => { + const aFolder = a.typeId === "folder"; + const bFolder = b.typeId === "folder"; + if (aFolder && !bFolder) return -1; + if (!aFolder && bFolder) return 1; + return a.zOrder - b.zOrder; + }); + + if (loading) { + return ( +
+ {t("homepage.loading")} +
+ ); + } + + return ( +
+ + +
+ {sortedWidgets.map((widget) => ( + + handleSaveEdit(widget.id, widget.title, config) + } + /> + ))} +
+ + {widgets.length === 0 && !isReadOnly && ( +
+
+ + + {t("homepage.noWidgets")} + +
+
+ )} + + {isReadOnly && onOpenFullscreen && ( + + )} + + {!isReadOnly && ( + + setIsLocked((v) => { + const next = !v; + localStorage.setItem("homepage.locked", String(next)); + return next; + }) + } + onResetView={resetView} + onAddWidget={handleOpenAddMenu} + /> + )} + + setContextMenu((s) => ({ ...s, visible: false }))} + /> + + setEditingWidget(null)} + /> +
+ ); +} diff --git a/src/ui/features/homepage/canvas/CanvasDotBackground.tsx b/src/ui/features/homepage/canvas/CanvasDotBackground.tsx new file mode 100644 index 00000000..63209186 --- /dev/null +++ b/src/ui/features/homepage/canvas/CanvasDotBackground.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from "react"; + +interface Props { + pan: { x: number; y: number }; + zoom: number; +} + +export function CanvasDotBackground({ pan, zoom }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.offsetWidth; + const h = canvas.offsetHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + + ctx.clearRect(0, 0, w, h); + + // Read dot color from CSS variable for theme support + const dotColor = + getComputedStyle(document.documentElement) + .getPropertyValue("--color-muted-foreground") + .trim() || "#6b7280"; + + // Effective grid spacing in screen pixels + const spacing = 30 * zoom; + + // Skip rendering dots that are too close together or too far apart + if (spacing < 8 || spacing > 200) return; + + // Offset so dots align with the canvas pan position + const offsetX = ((pan.x % spacing) + spacing) % spacing; + const offsetY = ((pan.y % spacing) + spacing) % spacing; + + const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2)); + + ctx.fillStyle = dotColor; + ctx.globalAlpha = 0.35; + + for (let x = offsetX; x < w; x += spacing) { + for (let y = offsetY; y < h; y += spacing) { + ctx.beginPath(); + ctx.arc(x, y, dotRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + }, [pan, zoom]); + + return ( + + ); +} diff --git a/src/ui/features/homepage/canvas/canvasGeometry.test.ts b/src/ui/features/homepage/canvas/canvasGeometry.test.ts new file mode 100644 index 00000000..a22655af --- /dev/null +++ b/src/ui/features/homepage/canvas/canvasGeometry.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + screenToCanvas, + canvasToScreen, + zoomAroundPoint, +} from "./canvasGeometry"; + +const PAN = { x: 100, y: 200 }; +const ZOOM = 2; + +describe("screenToCanvas", () => { + it("inverts canvasToScreen", () => { + const canvas = { x: 50, y: 75 }; + const screen = canvasToScreen(canvas.x, canvas.y, PAN, ZOOM); + const back = screenToCanvas(screen.x, screen.y, PAN, ZOOM); + expect(back.x).toBeCloseTo(canvas.x); + expect(back.y).toBeCloseTo(canvas.y); + }); + + it("accounts for pan offset", () => { + const result = screenToCanvas(100, 200, PAN, 1); + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + it("accounts for zoom", () => { + const result = screenToCanvas(300, 400, PAN, ZOOM); + expect(result.x).toBe((300 - 100) / 2); + expect(result.y).toBe((400 - 200) / 2); + }); +}); + +describe("canvasToScreen", () => { + it("maps origin with pan offset", () => { + const result = canvasToScreen(0, 0, PAN, ZOOM); + expect(result.x).toBe(100); + expect(result.y).toBe(200); + }); + + it("scales by zoom", () => { + const result = canvasToScreen(10, 20, PAN, ZOOM); + expect(result.x).toBe(120); + expect(result.y).toBe(240); + }); +}); + +describe("zoomAroundPoint", () => { + it("keeps the mouse point on the same canvas coordinate", () => { + const mouseX = 400; + const mouseY = 300; + const pan = { x: 0, y: 0 }; + const oldZoom = 1; + const newZoom = 2; + + const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom); + + // Canvas coordinate under mouse before zoom + const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom); + // Canvas coordinate under mouse after zoom with newPan + const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom); + + expect(canvasAfter.x).toBeCloseTo(canvasBefore.x); + expect(canvasAfter.y).toBeCloseTo(canvasBefore.y); + }); + + it("returns correct pan for zoom-out", () => { + const mouseX = 200; + const mouseY = 150; + const pan = { x: 50, y: 50 }; + const oldZoom = 2; + const newZoom = 1; + + const newPan = zoomAroundPoint(mouseX, mouseY, pan, oldZoom, newZoom); + const canvasBefore = screenToCanvas(mouseX, mouseY, pan, oldZoom); + const canvasAfter = screenToCanvas(mouseX, mouseY, newPan, newZoom); + + expect(canvasAfter.x).toBeCloseTo(canvasBefore.x); + expect(canvasAfter.y).toBeCloseTo(canvasBefore.y); + }); +}); diff --git a/src/ui/features/homepage/canvas/canvasGeometry.ts b/src/ui/features/homepage/canvas/canvasGeometry.ts new file mode 100644 index 00000000..b6a807ec --- /dev/null +++ b/src/ui/features/homepage/canvas/canvasGeometry.ts @@ -0,0 +1,39 @@ +export function screenToCanvas( + screenX: number, + screenY: number, + pan: { x: number; y: number }, + zoom: number, +): { x: number; y: number } { + return { + x: (screenX - pan.x) / zoom, + y: (screenY - pan.y) / zoom, + }; +} + +export function canvasToScreen( + canvasX: number, + canvasY: number, + pan: { x: number; y: number }, + zoom: number, +): { x: number; y: number } { + return { + x: canvasX * zoom + pan.x, + y: canvasY * zoom + pan.y, + }; +} + +/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */ +export function zoomAroundPoint( + mouseX: number, + mouseY: number, + pan: { x: number; y: number }, + oldZoom: number, + newZoom: number, +): { x: number; y: number } { + const gridX = (mouseX - pan.x) / oldZoom; + const gridY = (mouseY - pan.y) / oldZoom; + return { + x: mouseX - gridX * newZoom, + y: mouseY - gridY * newZoom, + }; +} diff --git a/src/ui/features/homepage/canvas/snapToGrid.test.ts b/src/ui/features/homepage/canvas/snapToGrid.test.ts new file mode 100644 index 00000000..9bc088ac --- /dev/null +++ b/src/ui/features/homepage/canvas/snapToGrid.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { snapToGrid, snapToGridFloor } from "./snapToGrid"; +import { GRID_SIZE } from "@/types/homepage-types"; + +describe("snapToGrid", () => { + it("snaps to nearest grid multiple", () => { + expect(snapToGrid(0)).toBe(0); + expect(snapToGrid(GRID_SIZE)).toBe(GRID_SIZE); + expect(snapToGrid(GRID_SIZE * 2)).toBe(GRID_SIZE * 2); + }); + + it("rounds to nearest (up when exactly halfway)", () => { + expect(snapToGrid(GRID_SIZE / 2)).toBe(GRID_SIZE); + expect(snapToGrid(GRID_SIZE * 1.5)).toBe(GRID_SIZE * 2); + }); + + it("rounds down when below halfway", () => { + expect(snapToGrid(GRID_SIZE * 0.4)).toBe(0); + expect(snapToGrid(GRID_SIZE * 1.4)).toBe(GRID_SIZE); + }); + + it("works with negative values", () => { + expect(snapToGrid(-GRID_SIZE)).toBe(-GRID_SIZE); + expect(snapToGrid(-GRID_SIZE * 0.4)).toBeCloseTo(0); + }); +}); + +describe("snapToGridFloor", () => { + it("always rounds down to floor multiple", () => { + expect(snapToGridFloor(GRID_SIZE - 1)).toBe(0); + expect(snapToGridFloor(GRID_SIZE)).toBe(GRID_SIZE); + expect(snapToGridFloor(GRID_SIZE + 1)).toBe(GRID_SIZE); + expect(snapToGridFloor(GRID_SIZE * 2 - 1)).toBe(GRID_SIZE); + }); + + it("handles zero", () => { + expect(snapToGridFloor(0)).toBe(0); + }); +}); diff --git a/src/ui/features/homepage/canvas/snapToGrid.ts b/src/ui/features/homepage/canvas/snapToGrid.ts new file mode 100644 index 00000000..27532fd1 --- /dev/null +++ b/src/ui/features/homepage/canvas/snapToGrid.ts @@ -0,0 +1,9 @@ +import { GRID_SIZE } from "@/types/homepage-types"; + +export function snapToGrid(value: number): number { + return Math.round(value / GRID_SIZE) * GRID_SIZE; +} + +export function snapToGridFloor(value: number): number { + return Math.floor(value / GRID_SIZE) * GRID_SIZE; +} diff --git a/src/ui/features/homepage/canvas/useBoxDrag.ts b/src/ui/features/homepage/canvas/useBoxDrag.ts new file mode 100644 index 00000000..ed3d1e20 --- /dev/null +++ b/src/ui/features/homepage/canvas/useBoxDrag.ts @@ -0,0 +1,66 @@ +import { useCallback, useRef } from "react"; +import type { CanvasWidget, DragState } from "@/types/homepage-types"; +import { snapToGrid } from "./snapToGrid"; + +interface UseBoxDragOptions { + widgets: CanvasWidget[]; + zoom: number; + getZoom: () => number; + onWidgetMove: (id: number, x: number, y: number) => void; + onDragStart: (id: number) => void; + onDragEnd: () => void; +} + +export function useBoxDrag({ + widgets, + getZoom, + onWidgetMove, + onDragStart, + onDragEnd, +}: UseBoxDragOptions) { + const dragRef = useRef(null); + + const startDrag = useCallback( + (e: React.MouseEvent, widget: CanvasWidget) => { + e.stopPropagation(); + e.preventDefault(); + dragRef.current = { + widgetId: widget.id, + startMouseX: e.clientX, + startMouseY: e.clientY, + startWidgetX: widget.x, + startWidgetY: widget.y, + }; + onDragStart(widget.id); + + const handleMove = (ev: MouseEvent) => { + if (!dragRef.current) return; + const zoom = getZoom(); + const dx = (ev.clientX - dragRef.current.startMouseX) / zoom; + const dy = (ev.clientY - dragRef.current.startMouseY) / zoom; + const newX = snapToGrid(dragRef.current.startWidgetX + dx); + const newY = snapToGrid(dragRef.current.startWidgetY + dy); + + const moving = widgets.find((w) => w.id === dragRef.current!.widgetId); + if (!moving) return; + + if (newX !== moving.x || newY !== moving.y) { + onWidgetMove(moving.id, newX, newY); + } + }; + + const handleUp = () => { + dragRef.current = null; + onDragEnd(); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [widgets, getZoom, onWidgetMove, onDragStart, onDragEnd], + ); + + return { startDrag }; +} diff --git a/src/ui/features/homepage/canvas/useBoxResize.ts b/src/ui/features/homepage/canvas/useBoxResize.ts new file mode 100644 index 00000000..9ccce5b5 --- /dev/null +++ b/src/ui/features/homepage/canvas/useBoxResize.ts @@ -0,0 +1,82 @@ +import { useCallback, useRef } from "react"; +import { GRID_SIZE } from "@/types/homepage-types"; +import type { CanvasWidget, ResizeState } from "@/types/homepage-types"; +import { snapToGrid } from "./snapToGrid"; +import { getWidgetType } from "../widgets/WidgetRegistry"; + +interface UseBoxResizeOptions { + widgets: CanvasWidget[]; + getZoom: () => number; + onWidgetResize: (id: number, w: number, h: number) => void; + onResizeStart: (id: number) => void; + onResizeEnd: () => void; +} + +export function useBoxResize({ + widgets, + getZoom, + onWidgetResize, + onResizeStart, + onResizeEnd, +}: UseBoxResizeOptions) { + const resizeRef = useRef(null); + + const startResize = useCallback( + (e: React.MouseEvent, widget: CanvasWidget) => { + e.stopPropagation(); + e.preventDefault(); + resizeRef.current = { + widgetId: widget.id, + startMouseX: e.clientX, + startMouseY: e.clientY, + startW: widget.w, + startH: widget.h, + }; + onResizeStart(widget.id); + + const handleMove = (ev: MouseEvent) => { + if (!resizeRef.current) return; + const zoom = getZoom(); + const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom; + const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom; + const newW = Math.max( + GRID_SIZE * 2, + snapToGrid(resizeRef.current.startW + dx), + ); + const newH = Math.max( + GRID_SIZE * 2, + snapToGrid(resizeRef.current.startH + dy), + ); + + const moving = widgets.find( + (w) => w.id === resizeRef.current!.widgetId, + ); + if (!moving) return; + + const typeDef = getWidgetType(moving.typeId); + const minW = typeDef?.minSize.w ?? GRID_SIZE * 4; + const minH = typeDef?.minSize.h ?? GRID_SIZE * 4; + const clampedW = Math.max(minW, newW); + const clampedH = Math.max(minH, newH); + + if (clampedW !== moving.w || clampedH !== moving.h) { + onWidgetResize(moving.id, clampedW, clampedH); + } + }; + + const handleUp = () => { + resizeRef.current = null; + onResizeEnd(); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + window.addEventListener("mouseup", handleUp); + }, + [widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd], + ); + + return { startResize }; +} diff --git a/src/ui/features/homepage/canvas/useCanvasInput.ts b/src/ui/features/homepage/canvas/useCanvasInput.ts new file mode 100644 index 00000000..b27e6442 --- /dev/null +++ b/src/ui/features/homepage/canvas/useCanvasInput.ts @@ -0,0 +1,85 @@ +import { useCallback, useRef } from "react"; +import { MIN_ZOOM, MAX_ZOOM } from "@/types/homepage-types"; +import { zoomAroundPoint } from "./canvasGeometry"; + +interface UseCanvasInputOptions { + isDraggingWidget: boolean; + isResizing: boolean; + isContextMenuVisible: boolean; + onPanChange: (pan: { x: number; y: number }) => void; + onZoomChange: (zoom: number, pan: { x: number; y: number }) => void; + getPan: () => { x: number; y: number }; + getZoom: () => number; + containerRef: React.RefObject; +} + +export function useCanvasInput({ + isDraggingWidget, + isResizing, + isContextMenuVisible, + onPanChange, + onZoomChange, + getPan, + getZoom, + containerRef, +}: UseCanvasInputOptions) { + const isPanningRef = useRef(false); + const lastMouseRef = useRef({ x: 0, y: 0 }); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only pan on the background (not on widgets), left button + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest("[data-widget]")) return; + if (isDraggingWidget || isResizing || isContextMenuVisible) return; + + isPanningRef.current = true; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + e.preventDefault(); + }, + [isDraggingWidget, isResizing, isContextMenuVisible], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isPanningRef.current) return; + const dx = e.clientX - lastMouseRef.current.x; + const dy = e.clientY - lastMouseRef.current.y; + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + const pan = getPan(); + onPanChange({ x: pan.x + dx, y: pan.y + dy }); + }, + [getPan, onPanChange], + ); + + const handleMouseUp = useCallback(() => { + isPanningRef.current = false; + }, []); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const zoom = getZoom(); + const pan = getPan(); + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Multiplicative zoom so each step is proportional regardless of current zoom level. + // Normalize deltaY to avoid trackpad sending huge values. + const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100); + const factor = 1 - rawDelta * 0.001; + const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor)); + if (newZoom === zoom) return; + + const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom); + onZoomChange(newZoom, newPan); + }, + [getZoom, getPan, containerRef, onZoomChange], + ); + + return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel }; +} diff --git a/src/ui/features/homepage/dialogs/AddWidgetMenu.tsx b/src/ui/features/homepage/dialogs/AddWidgetMenu.tsx new file mode 100644 index 00000000..dbd28acc --- /dev/null +++ b/src/ui/features/homepage/dialogs/AddWidgetMenu.tsx @@ -0,0 +1,150 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getAllWidgetTypes } from "../widgets/WidgetRegistry"; +import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types"; + +interface AddWidgetMenuProps { + state: ContextMenuState; + onAdd: (typeId: WidgetTypeId, canvasX: number, canvasY: number) => void; + onClose: () => void; +} + +const CATEGORY_ORDER = ["links", "info", "system", "monitoring"] as const; + +const MARGIN = 8; + +export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) { + const { t } = useTranslation(); + const menuRef = useRef(null); + const allTypes = getAllWidgetTypes(); + const [pos, setPos] = useState({ left: state.screenX, top: state.screenY }); + const [selectedCat, setSelectedCat] = useState< + (typeof CATEGORY_ORDER)[number] + >(CATEGORY_ORDER[0]); + const categoryLabels: Record<(typeof CATEGORY_ORDER)[number], string> = { + links: t("homepage.categoryLinks"), + info: t("homepage.categoryInfo"), + system: t("homepage.categorySystem"), + monitoring: t("homepage.categoryMonitoring"), + }; + + useLayoutEffect(() => { + if (!state.visible || !menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let left = state.screenX; + let top = state.screenY; + + if (state.anchorRect) { + left = state.anchorRect.right - rect.width; + top = state.anchorRect.top - rect.height - MARGIN; + } + + if (left + rect.width > vw - MARGIN) left = vw - rect.width - MARGIN; + if (left < MARGIN) left = MARGIN; + + if (top < MARGIN) { + top = state.anchorRect + ? state.anchorRect.bottom + MARGIN + : state.screenY + MARGIN; + } + if (top + rect.height > vh - MARGIN) top = vh - rect.height - MARGIN; + if (top < MARGIN) top = MARGIN; + + setPos({ left, top }); + }, [state.visible, state.screenX, state.screenY, state.anchorRect]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [onClose]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + const visibleCategories = CATEGORY_ORDER.filter((cat) => + allTypes.some((type) => type.category === cat), + ); + + const selectedTypes = allTypes.filter( + (type) => type.category === selectedCat, + ); + + return ( +
e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + > + {/* Header */} +
+ + {t("homepage.addWidget")} + +
+ + {/* Category tabs */} +
+ {visibleCategories.map((cat) => ( + + ))} +
+ + {/* Widget list */} +
+ {selectedTypes.length === 0 ? ( +
+ No widgets in this category +
+ ) : ( + selectedTypes.map((type) => ( + + )) + )} +
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/AlertFeedEditForm.tsx b/src/ui/features/homepage/dialogs/AlertFeedEditForm.tsx new file mode 100644 index 00000000..a3d5cb57 --- /dev/null +++ b/src/ui/features/homepage/dialogs/AlertFeedEditForm.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; +import type { + AlertFeedConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; +import { Input } from "@/components/input"; + +export function AlertFeedEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + + onChange({ + ...config, + maxItems: Math.max(1, Number(e.target.value)), + }) + } + className="h-8 text-xs" + /> +
+ +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/BookmarkListEditForm.tsx b/src/ui/features/homepage/dialogs/BookmarkListEditForm.tsx new file mode 100644 index 00000000..9f744e14 --- /dev/null +++ b/src/ui/features/homepage/dialogs/BookmarkListEditForm.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import { Button } from "@/components/button"; +import { Trash2, Plus } from "lucide-react"; +import type { + BookmarkListConfig, + BookmarkLink, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function BookmarkListEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + const { links } = config; + + const updateLink = (i: number, patch: Partial) => { + const next = links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)); + onChange({ ...config, links: next }); + }; + + const removeLink = (i: number) => { + onChange({ ...config, links: links.filter((_, idx) => idx !== i) }); + }; + + const addLink = () => { + onChange({ ...config, links: [...links, { label: "", url: "" }] }); + }; + + return ( +
+ {links.map((link, i) => ( +
+ updateLink(i, { label: e.target.value })} + placeholder={t("homepage.linkLabel")} + className="h-7 text-xs flex-1" + /> + updateLink(i, { url: e.target.value })} + placeholder="https://..." + className="h-7 text-xs flex-1" + /> + +
+ ))} + +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/CalendarEditForm.tsx b/src/ui/features/homepage/dialogs/CalendarEditForm.tsx new file mode 100644 index 00000000..333219ec --- /dev/null +++ b/src/ui/features/homepage/dialogs/CalendarEditForm.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + CalendarConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function CalendarEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + + onChange({ ...config, timezone: e.target.value || undefined }) + } + placeholder="UTC" + className="h-8 text-xs" + /> +
+ +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/ClockEditForm.tsx b/src/ui/features/homepage/dialogs/ClockEditForm.tsx new file mode 100644 index 00000000..5f4aea5e --- /dev/null +++ b/src/ui/features/homepage/dialogs/ClockEditForm.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import { Switch } from "@/components/switch"; +import type { ClockConfig, WidgetEditFormProps } from "@/types/homepage-types"; + +export function ClockEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + + onChange({ ...config, timezone: e.target.value || undefined }) + } + placeholder="America/New_York (leave blank for local)" + className="h-8 text-sm" + /> +
+
+ + onChange({ ...config, showSeconds: v })} + /> +
+
+ + + onChange({ ...config, format: v ? "12h" : "24h" }) + } + /> +
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/CountdownEditForm.tsx b/src/ui/features/homepage/dialogs/CountdownEditForm.tsx new file mode 100644 index 00000000..810594b5 --- /dev/null +++ b/src/ui/features/homepage/dialogs/CountdownEditForm.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + CountdownConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function CountdownEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + onChange({ ...config, label: e.target.value })} + placeholder={t("homepage.countdownLabelPlaceholder")} + className="h-8 text-xs" + /> +
+
+ + onChange({ ...config, targetDate: e.target.value })} + className="h-8 text-xs" + /> +
+ + +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/CustomApiEditForm.tsx b/src/ui/features/homepage/dialogs/CustomApiEditForm.tsx new file mode 100644 index 00000000..d32de478 --- /dev/null +++ b/src/ui/features/homepage/dialogs/CustomApiEditForm.tsx @@ -0,0 +1,137 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + CustomApiConfig, + CustomApiDisplayMode, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +const MODES: { id: CustomApiDisplayMode; label: string }[] = [ + { id: "value", label: "Value" }, + { id: "json", label: "JSON" }, + { id: "table", label: "Table" }, +]; + +export function CustomApiEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + onChange({ ...config, url: e.target.value })} + placeholder="https://api.example.com/data" + className="h-8 text-xs" + /> +
+ +
+ +
+ {MODES.map((m) => ( + + ))} +
+
+ + {config.displayMode === "value" && ( + <> +
+ + + onChange({ + ...config, + displayField: e.target.value || undefined, + }) + } + placeholder="data.temperature" + className="h-8 text-xs" + /> +
+
+
+ + + onChange({ ...config, label: e.target.value || undefined }) + } + placeholder={t("homepage.customApiLabelPlaceholder")} + className="h-8 text-xs" + /> +
+
+ + + onChange({ ...config, unit: e.target.value || undefined }) + } + placeholder="°C" + className="h-8 text-xs" + /> +
+
+ + )} + + {config.displayMode === "table" && ( +
+ + + onChange({ ...config, jsonPath: e.target.value || undefined }) + } + placeholder="data.items" + className="h-8 text-xs" + /> +
+ )} + +
+ + + onChange({ + ...config, + refreshInterval: Math.max(10, Number(e.target.value)), + }) + } + className="h-8 text-xs" + /> +
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/DashboardLinksEditForm.tsx b/src/ui/features/homepage/dialogs/DashboardLinksEditForm.tsx new file mode 100644 index 00000000..c4396d5a --- /dev/null +++ b/src/ui/features/homepage/dialogs/DashboardLinksEditForm.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + DashboardLinksConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function DashboardLinksEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ +
+ {([1, 2, 3] as const).map((c) => ( + + ))} +
+
+ +
+ + + onChange({ + ...config, + maxItems: e.target.value ? Number(e.target.value) : undefined, + }) + } + placeholder={t("homepage.noLimit")} + className="h-8 text-xs" + /> +
+ + +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/DockerActivityEditForm.tsx b/src/ui/features/homepage/dialogs/DockerActivityEditForm.tsx new file mode 100644 index 00000000..63c9b0ff --- /dev/null +++ b/src/ui/features/homepage/dialogs/DockerActivityEditForm.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + DockerActivityConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function DockerActivityEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + + onChange({ + ...config, + maxItems: Math.max(1, Number(e.target.value)), + }) + } + className="h-8 text-xs" + /> +
+ +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/DockerWidgetEditForm.tsx b/src/ui/features/homepage/dialogs/DockerWidgetEditForm.tsx new file mode 100644 index 00000000..bbe779ee --- /dev/null +++ b/src/ui/features/homepage/dialogs/DockerWidgetEditForm.tsx @@ -0,0 +1,18 @@ +import type { + DockerWidgetConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; +import { SingleHostEditForm } from "./SingleHostEditForm"; + +export function DockerWidgetEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + return ( + onChange({ ...config, hostId })} + filter={(h) => !!(h.enableSsh && h.enableDocker)} + /> + ); +} diff --git a/src/ui/features/homepage/dialogs/FileManagerWidgetEditForm.tsx b/src/ui/features/homepage/dialogs/FileManagerWidgetEditForm.tsx new file mode 100644 index 00000000..2028981e --- /dev/null +++ b/src/ui/features/homepage/dialogs/FileManagerWidgetEditForm.tsx @@ -0,0 +1,18 @@ +import type { + FileManagerWidgetConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; +import { SingleHostEditForm } from "./SingleHostEditForm"; + +export function FileManagerWidgetEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + return ( + onChange({ ...config, hostId })} + filter={(h) => !!(h.enableSsh && h.enableFileManager)} + /> + ); +} diff --git a/src/ui/features/homepage/dialogs/FolderEditForm.tsx b/src/ui/features/homepage/dialogs/FolderEditForm.tsx new file mode 100644 index 00000000..31a36f4f --- /dev/null +++ b/src/ui/features/homepage/dialogs/FolderEditForm.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { FolderConfig, WidgetEditFormProps } from "@/types/homepage-types"; + +function getAccentColor(): string { + return ( + getComputedStyle(document.documentElement) + .getPropertyValue("--accent-brand") + .trim() || "#f59145" + ); +} + +export function FolderEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + const accent = getAccentColor(); + return ( +
+
+ +
+ onChange({ ...config, color: e.target.value })} + className="w-8 h-8 border border-border cursor-pointer" + /> + + onChange({ ...config, color: e.target.value || undefined }) + } + placeholder={accent} + className="h-8 text-sm flex-1" + /> +
+
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/HostGridEditForm.tsx b/src/ui/features/homepage/dialogs/HostGridEditForm.tsx new file mode 100644 index 00000000..fbb2bdee --- /dev/null +++ b/src/ui/features/homepage/dialogs/HostGridEditForm.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + HostGridConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; +import { getSSHHosts } from "@/api/ssh-host-management-api"; + +export function HostGridEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]); + + useEffect(() => { + getSSHHosts() + .then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name })))) + .catch(() => {}); + }, []); + + const toggleHost = (id: number) => { + const next = config.hostIds.includes(id) + ? config.hostIds.filter((x) => x !== id) + : [...config.hostIds, id]; + onChange({ ...config, hostIds: next }); + }; + + return ( +
+
+ +
+ {hosts.length === 0 && ( + + {t("homepage.noHosts")} + + )} + {hosts.map((h) => ( + + ))} +
+ + {t("homepage.hostGridAllHint")} + +
+ +
+ +
+ {([2, 3, 4] as const).map((c) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/HostStatusEditForm.tsx b/src/ui/features/homepage/dialogs/HostStatusEditForm.tsx new file mode 100644 index 00000000..855f8931 --- /dev/null +++ b/src/ui/features/homepage/dialogs/HostStatusEditForm.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { getSSHHosts } from "@/api/ssh-host-management-api"; +import type { + HostMetricKey, + HostStatusConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +interface SimpleHost { + id: string; + name: string; + metricsEnabled: boolean; +} + +const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [ + { key: "cpu", labelKey: "homepage.metricCpu" }, + { key: "memory", labelKey: "homepage.metricMemory" }, + { key: "disk", labelKey: "homepage.metricDisk" }, + { key: "uptime", labelKey: "homepage.metricUptime" }, + { key: "system", labelKey: "homepage.metricSystem" }, + { key: "network", labelKey: "homepage.metricNetwork" }, + { key: "processes", labelKey: "homepage.metricProcesses" }, +]; + +function parseMetricsEnabled(statsConfig: unknown): boolean { + if (!statsConfig) return false; + try { + const cfg = + typeof statsConfig === "string" ? JSON.parse(statsConfig) : statsConfig; + if (typeof cfg === "object" && cfg !== null) { + const c = cfg as Record; + return c.metricsEnabled !== false; + } + } catch { + // ignore + } + return false; +} + +export function HostStatusEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + const [hosts, setHosts] = useState([]); + + const shownMetrics: HostMetricKey[] = config.shownMetrics?.length + ? config.shownMetrics + : config.showMetrics !== false + ? ["cpu", "memory", ...(config.showDisk ? ["disk" as HostMetricKey] : [])] + : []; + + useEffect(() => { + getSSHHosts() + .then((all) => + setHosts( + all + .filter((x) => parseMetricsEnabled(x.statsConfig)) + .map((x) => ({ + id: String(x.id), + name: x.name, + metricsEnabled: true, + })), + ), + ) + .catch(() => {}); + }, []); + + function toggleMetric(key: HostMetricKey) { + const next = shownMetrics.includes(key) + ? shownMetrics.filter((k) => k !== key) + : [...shownMetrics, key]; + onChange({ ...config, shownMetrics: next }); + } + + return ( +
+
+ + +
+ +
+ +
+ {METRIC_OPTIONS.map(({ key, labelKey }) => ( + + ))} +
+
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/IframeEditForm.tsx b/src/ui/features/homepage/dialogs/IframeEditForm.tsx new file mode 100644 index 00000000..3c770836 --- /dev/null +++ b/src/ui/features/homepage/dialogs/IframeEditForm.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import { Switch } from "@/components/switch"; +import type { IframeConfig, WidgetEditFormProps } from "@/types/homepage-types"; + +export function IframeEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + onChange({ ...config, url: e.target.value })} + placeholder="https://example.com" + className="h-8 text-sm" + /> +
+
+ + onChange({ ...config, scrolling: v })} + /> +
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/ImageWidgetEditForm.tsx b/src/ui/features/homepage/dialogs/ImageWidgetEditForm.tsx new file mode 100644 index 00000000..367f22d1 --- /dev/null +++ b/src/ui/features/homepage/dialogs/ImageWidgetEditForm.tsx @@ -0,0 +1,71 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import type { + ImageWidgetConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function ImageWidgetEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ + onChange({ ...config, imageUrl: e.target.value })} + placeholder="https://example.com/image.png" + className="h-8 text-xs" + /> +
+
+ + + onChange({ ...config, linkUrl: e.target.value || undefined }) + } + placeholder="https://example.com" + className="h-8 text-xs" + /> +
+
+ +
+ {(["contain", "cover", "fill"] as const).map((f) => ( + + ))} +
+
+
+ + + onChange({ ...config, alt: e.target.value || undefined }) + } + placeholder={t("homepage.altTextPlaceholder")} + className="h-8 text-xs" + /> +
+
+ ); +} diff --git a/src/ui/features/homepage/dialogs/LinkTreeEditForm.tsx b/src/ui/features/homepage/dialogs/LinkTreeEditForm.tsx new file mode 100644 index 00000000..aa4ec060 --- /dev/null +++ b/src/ui/features/homepage/dialogs/LinkTreeEditForm.tsx @@ -0,0 +1,134 @@ +import { useTranslation } from "react-i18next"; +import { Trash2, Plus } from "lucide-react"; +import { Input } from "@/components/input"; +import { Button } from "@/components/button"; +import type { + LinkTreeConfig, + LinkTreeSection, + LinkTreeLink, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function LinkTreeEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + const { sections, compact } = config; + + const updateSection = (si: number, patch: Partial) => { + onChange({ + ...config, + sections: sections.map((s, i) => (i === si ? { ...s, ...patch } : s)), + }); + }; + const removeSection = (si: number) => + onChange({ ...config, sections: sections.filter((_, i) => i !== si) }); + const addSection = () => + onChange({ + ...config, + sections: [...sections, { heading: "", links: [] }], + }); + + const updateLink = (si: number, li: number, patch: Partial) => { + const newSections = sections.map((s, i) => { + if (i !== si) return s; + return { + ...s, + links: s.links.map((l, j) => (j === li ? { ...l, ...patch } : l)), + }; + }); + onChange({ ...config, sections: newSections }); + }; + const removeLink = (si: number, li: number) => { + const newSections = sections.map((s, i) => + i !== si ? s : { ...s, links: s.links.filter((_, j) => j !== li) }, + ); + onChange({ ...config, sections: newSections }); + }; + const addLink = (si: number) => { + const newSections = sections.map((s, i) => + i !== si ? s : { ...s, links: [...s.links, { label: "", url: "" }] }, + ); + onChange({ ...config, sections: newSections }); + }; + + return ( +
+
+ {sections.map((section, si) => ( +
+
+ updateSection(si, { heading: e.target.value })} + placeholder={t("homepage.sectionHeading")} + className="h-6 text-xs flex-1 font-semibold" + /> + +
+
+ {section.links.map((link, li) => ( +
+ + updateLink(si, li, { label: e.target.value }) + } + placeholder={t("homepage.linkLabel")} + className="h-6 text-[10px] flex-1" + /> + + updateLink(si, li, { url: e.target.value }) + } + placeholder="https://..." + className="h-6 text-[10px] flex-1" + /> + +
+ ))} + +
+
+ ))} + +
+ +
+ ); +} diff --git a/src/ui/features/homepage/dialogs/MarkdownNotesEditForm.tsx b/src/ui/features/homepage/dialogs/MarkdownNotesEditForm.tsx new file mode 100644 index 00000000..8b41f44b --- /dev/null +++ b/src/ui/features/homepage/dialogs/MarkdownNotesEditForm.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/input"; +import { Textarea } from "@/components/textarea"; +import type { + MarkdownNotesConfig, + WidgetEditFormProps, +} from "@/types/homepage-types"; + +export function MarkdownNotesEditForm({ + config, + onChange, +}: WidgetEditFormProps) { + const { t } = useTranslation(); + return ( +
+
+ +