Repository: Termix-SSH/Termix Branch: main Commit: 69eca2652bb9 Files: 377 Total size: 8.7 MB Directory structure: gitextract_m9ntu940/ ├── .commitlintrc.json ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── config.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── docker.yml │ ├── electron.yml │ ├── openapi.yml │ └── pr-check.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Casks/ │ └── termix.rb ├── LICENSE ├── README.md ├── SECURITY.md ├── build/ │ ├── Termix_Mac_App_Store.provisionprofile │ ├── entitlements.mac.inherit.plist │ ├── entitlements.mac.plist │ ├── entitlements.mas.inherit.plist │ ├── entitlements.mas.plist │ └── notarize.cjs ├── chocolatey/ │ ├── termix-ssh.nuspec │ └── tools/ │ ├── chocolateyinstall.ps1 │ └── chocolateyuninstall.ps1 ├── components.json ├── crowdin.yml ├── docker/ │ ├── Dockerfile │ ├── docker-compose.yml │ ├── entrypoint.sh │ ├── nginx-https.conf │ └── nginx.conf ├── electron/ │ ├── main.cjs │ └── preload.js ├── electron-builder.json ├── eslint.config.js ├── flatpak/ │ ├── com.karmaa.termix.desktop │ ├── com.karmaa.termix.flatpakref │ ├── com.karmaa.termix.metainfo.xml │ ├── com.karmaa.termix.yml │ └── flathub.json ├── index.html ├── package.json ├── public/ │ ├── icon.icns │ ├── icons/ │ │ └── icon.icns │ ├── manifest.json │ └── sw.js ├── readme/ │ ├── README-AR.md │ ├── README-CN.md │ ├── README-DE.md │ ├── README-ES.md │ ├── README-FR.md │ ├── README-HI.md │ ├── README-IT.md │ ├── README-JA.md │ ├── README-KO.md │ ├── README-PT.md │ ├── README-RU.md │ ├── README-TR.md │ └── README-VI.md ├── src/ │ ├── backend/ │ │ ├── dashboard.ts │ │ ├── database/ │ │ │ ├── database.ts │ │ │ ├── db/ │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── routes/ │ │ │ ├── alerts.ts │ │ │ ├── credentials.ts │ │ │ ├── host.ts │ │ │ ├── network-topology.ts │ │ │ ├── rbac.ts │ │ │ ├── snippets.ts │ │ │ ├── terminal.ts │ │ │ └── users.ts │ │ ├── guacamole/ │ │ │ ├── guacamole-server.ts │ │ │ ├── routes.ts │ │ │ └── token-service.ts │ │ ├── scripts/ │ │ │ ├── enable-ssl.sh │ │ │ └── setup-ssl.sh │ │ ├── ssh/ │ │ │ ├── auth-manager.ts │ │ │ ├── docker-console.ts │ │ │ ├── docker.ts │ │ │ ├── file-manager.ts │ │ │ ├── host-key-verifier.ts │ │ │ ├── opkssh-auth.ts │ │ │ ├── server-stats.ts │ │ │ ├── ssh-connection-pool.ts │ │ │ ├── terminal-session-manager.ts │ │ │ ├── terminal.ts │ │ │ ├── tunnel.ts │ │ │ └── widgets/ │ │ │ ├── common-utils.ts │ │ │ ├── cpu-collector.ts │ │ │ ├── disk-collector.ts │ │ │ ├── firewall-collector.ts │ │ │ ├── login-stats-collector.ts │ │ │ ├── memory-collector.ts │ │ │ ├── network-collector.ts │ │ │ ├── ports-collector.ts │ │ │ ├── processes-collector.ts │ │ │ ├── system-collector.ts │ │ │ └── uptime-collector.ts │ │ ├── starter.ts │ │ ├── swagger.ts │ │ └── utils/ │ │ ├── auth-manager.ts │ │ ├── auto-ssl-setup.ts │ │ ├── credential-system-encryption-migration.ts │ │ ├── data-crypto.ts │ │ ├── database-file-encryption.ts │ │ ├── database-migration.ts │ │ ├── database-save-trigger.ts │ │ ├── field-crypto.ts │ │ ├── lazy-field-encryption.ts │ │ ├── logger.ts │ │ ├── login-rate-limiter.ts │ │ ├── opkssh-binary-manager.ts │ │ ├── permission-manager.ts │ │ ├── proxy-agent.ts │ │ ├── proxy-helper.ts │ │ ├── request-origin.ts │ │ ├── shared-credential-manager.ts │ │ ├── simple-db-ops.ts │ │ ├── socks5-helper.ts │ │ ├── ssh-key-utils.ts │ │ ├── system-crypto.ts │ │ ├── user-agent-parser.ts │ │ ├── user-crypto.ts │ │ ├── user-data-export.ts │ │ └── user-data-import.ts │ ├── components/ │ │ ├── theme-provider.tsx │ │ └── ui/ │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── badge.tsx │ │ ├── button-group.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── kbd.tsx │ │ ├── label.tsx │ │ ├── password-input.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── shadcn-io/ │ │ │ └── status/ │ │ │ └── index.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── tooltip.tsx │ │ └── version-alert.tsx │ ├── constants/ │ │ └── terminal-themes.ts │ ├── hooks/ │ │ ├── use-confirmation.ts │ │ ├── use-mobile.ts │ │ └── use-service-worker.ts │ ├── i18n/ │ │ └── i18n.ts │ ├── index.css │ ├── lib/ │ │ ├── base-path.ts │ │ ├── clipboard-provider.ts │ │ ├── db-health-monitor.ts │ │ ├── frontend-logger.ts │ │ ├── terminal-syntax-highlighter.ts │ │ └── utils.ts │ ├── locales/ │ │ ├── README.md │ │ ├── en.json │ │ └── translated/ │ │ ├── af_ZA.json │ │ ├── ar_SA.json │ │ ├── bg_BG.json │ │ ├── bn_BD.json │ │ ├── ca_ES.json │ │ ├── cs_CZ.json │ │ ├── da_DK.json │ │ ├── de_DE.json │ │ ├── el_GR.json │ │ ├── es_ES.json │ │ ├── fi_FI.json │ │ ├── fr_FR.json │ │ ├── he_IL.json │ │ ├── hi_IN.json │ │ ├── hu_HU.json │ │ ├── id_ID.json │ │ ├── it_IT.json │ │ ├── ja_JP.json │ │ ├── ko_KR.json │ │ ├── nl_NL.json │ │ ├── no_NO.json │ │ ├── pl_PL.json │ │ ├── pt_BR.json │ │ ├── pt_PT.json │ │ ├── ro_RO.json │ │ ├── ru_RU.json │ │ ├── sr_SP.json │ │ ├── sv_SE.json │ │ ├── th_TH.json │ │ ├── tr_TR.json │ │ ├── uk_UA.json │ │ ├── vi_VN.json │ │ ├── zh_CN.json │ │ └── zh_TW.json │ ├── main.tsx │ ├── types/ │ │ ├── connection-log.ts │ │ ├── electron.d.ts │ │ ├── guacamole-common-js.d.ts │ │ ├── index.ts │ │ └── stats-widgets.ts │ ├── ui/ │ │ ├── contexts/ │ │ │ └── ServerStatusContext.tsx │ │ ├── desktop/ │ │ │ ├── DesktopApp.tsx │ │ │ ├── apps/ │ │ │ │ ├── FullScreenAppWrapper.tsx │ │ │ │ ├── admin/ │ │ │ │ │ ├── AdminSettings.tsx │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ ├── CreateUserDialog.tsx │ │ │ │ │ │ ├── LinkAccountDialog.tsx │ │ │ │ │ │ └── UserEditDialog.tsx │ │ │ │ │ └── tabs/ │ │ │ │ │ ├── DatabaseSecurityTab.tsx │ │ │ │ │ ├── GeneralSettingsTab.tsx │ │ │ │ │ ├── OIDCSettingsTab.tsx │ │ │ │ │ ├── RolesTab.tsx │ │ │ │ │ ├── SessionManagementTab.tsx │ │ │ │ │ └── UserManagementTab.tsx │ │ │ │ ├── command-palette/ │ │ │ │ │ └── CommandPalette.tsx │ │ │ │ ├── dashboard/ │ │ │ │ │ ├── Dashboard.tsx │ │ │ │ │ ├── apps/ │ │ │ │ │ │ ├── UpdateLog.tsx │ │ │ │ │ │ └── alerts/ │ │ │ │ │ │ ├── AlertCard.tsx │ │ │ │ │ │ └── AlertManager.tsx │ │ │ │ │ ├── cards/ │ │ │ │ │ │ ├── NetworkGraphCard.tsx │ │ │ │ │ │ ├── QuickActionsCard.tsx │ │ │ │ │ │ ├── RecentActivityCard.tsx │ │ │ │ │ │ ├── ServerOverviewCard.tsx │ │ │ │ │ │ └── ServerStatsCard.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── DashboardSettingsDialog.tsx │ │ │ │ │ └── hooks/ │ │ │ │ │ └── useDashboardPreferences.ts │ │ │ │ ├── features/ │ │ │ │ │ ├── docker/ │ │ │ │ │ │ ├── DockerApp.tsx │ │ │ │ │ │ ├── DockerManager.tsx │ │ │ │ │ │ └── components/ │ │ │ │ │ │ ├── ConsoleTerminal.tsx │ │ │ │ │ │ ├── ContainerCard.tsx │ │ │ │ │ │ ├── ContainerDetail.tsx │ │ │ │ │ │ ├── ContainerList.tsx │ │ │ │ │ │ ├── ContainerStats.tsx │ │ │ │ │ │ └── LogViewer.tsx │ │ │ │ │ ├── file-manager/ │ │ │ │ │ │ ├── DragIndicator.tsx │ │ │ │ │ │ ├── FileManager.tsx │ │ │ │ │ │ ├── FileManagerApp.tsx │ │ │ │ │ │ ├── FileManagerContextMenu.tsx │ │ │ │ │ │ ├── FileManagerGrid.tsx │ │ │ │ │ │ ├── FileManagerSidebar.tsx │ │ │ │ │ │ ├── SudoPasswordDialog.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── CompressDialog.tsx │ │ │ │ │ │ │ ├── DiffViewer.tsx │ │ │ │ │ │ │ ├── DiffWindow.tsx │ │ │ │ │ │ │ ├── DraggableWindow.tsx │ │ │ │ │ │ │ ├── FileViewer.tsx │ │ │ │ │ │ │ ├── FileWindow.tsx │ │ │ │ │ │ │ ├── PermissionsDialog.tsx │ │ │ │ │ │ │ ├── TerminalWindow.tsx │ │ │ │ │ │ │ └── WindowManager.tsx │ │ │ │ │ │ └── hooks/ │ │ │ │ │ │ ├── useDragAndDrop.ts │ │ │ │ │ │ └── useFileSelection.ts │ │ │ │ │ ├── guacamole/ │ │ │ │ │ │ ├── GuacamoleApp.tsx │ │ │ │ │ │ └── GuacamoleDisplay.tsx │ │ │ │ │ ├── server-stats/ │ │ │ │ │ │ ├── ServerStats.tsx │ │ │ │ │ │ ├── ServerStatsApp.tsx │ │ │ │ │ │ └── widgets/ │ │ │ │ │ │ ├── CpuWidget.tsx │ │ │ │ │ │ ├── DiskWidget.tsx │ │ │ │ │ │ ├── FirewallWidget.tsx │ │ │ │ │ │ ├── LoginStatsWidget.tsx │ │ │ │ │ │ ├── MemoryWidget.tsx │ │ │ │ │ │ ├── NetworkWidget.tsx │ │ │ │ │ │ ├── PortsWidget.tsx │ │ │ │ │ │ ├── ProcessesWidget.tsx │ │ │ │ │ │ ├── SystemWidget.tsx │ │ │ │ │ │ ├── UptimeWidget.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── terminal/ │ │ │ │ │ │ ├── SudoPasswordPopup.tsx │ │ │ │ │ │ ├── Terminal.tsx │ │ │ │ │ │ ├── TerminalApp.tsx │ │ │ │ │ │ ├── TerminalPreview.tsx │ │ │ │ │ │ └── command-history/ │ │ │ │ │ │ ├── CommandAutocomplete.tsx │ │ │ │ │ │ └── CommandHistoryContext.tsx │ │ │ │ │ └── tunnel/ │ │ │ │ │ ├── Tunnel.tsx │ │ │ │ │ ├── TunnelApp.tsx │ │ │ │ │ ├── TunnelManager.tsx │ │ │ │ │ ├── TunnelObject.tsx │ │ │ │ │ └── TunnelViewer.tsx │ │ │ │ ├── host-manager/ │ │ │ │ │ ├── HostManagerApp.tsx │ │ │ │ │ ├── credentials/ │ │ │ │ │ │ ├── CredentialEditor.tsx │ │ │ │ │ │ ├── CredentialSelector.tsx │ │ │ │ │ │ ├── CredentialViewer.tsx │ │ │ │ │ │ ├── CredentialsManager.tsx │ │ │ │ │ │ └── tabs/ │ │ │ │ │ │ ├── CredentialAuthenticationTab.tsx │ │ │ │ │ │ ├── CredentialGeneralTab.tsx │ │ │ │ │ │ └── shared/ │ │ │ │ │ │ └── tab-types.ts │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ └── FolderEditDialog.tsx │ │ │ │ │ └── hosts/ │ │ │ │ │ ├── HostManager.tsx │ │ │ │ │ ├── HostManagerEditor.tsx │ │ │ │ │ ├── HostManagerViewer.tsx │ │ │ │ │ └── tabs/ │ │ │ │ │ ├── HostDockerTab.tsx │ │ │ │ │ ├── HostFileManagerTab.tsx │ │ │ │ │ ├── HostGeneralTab.tsx │ │ │ │ │ ├── HostRemoteDesktopTab.tsx │ │ │ │ │ ├── HostSharingTab.tsx │ │ │ │ │ ├── HostStatisticsTab.tsx │ │ │ │ │ ├── HostStatusTab.tsx │ │ │ │ │ ├── HostTerminalTab.tsx │ │ │ │ │ ├── HostTunnelTab.tsx │ │ │ │ │ └── shared/ │ │ │ │ │ ├── JumpHostItem.tsx │ │ │ │ │ ├── QuickActionItem.tsx │ │ │ │ │ └── tab-types.ts │ │ │ │ └── tools/ │ │ │ │ └── SSHToolsSidebar.tsx │ │ │ ├── authentication/ │ │ │ │ ├── Auth.tsx │ │ │ │ ├── ElectronLoginForm.tsx │ │ │ │ └── ElectronServerConfig.tsx │ │ │ ├── navigation/ │ │ │ │ ├── AppView.tsx │ │ │ │ ├── LeftSidebar.tsx │ │ │ │ ├── TopNavbar.tsx │ │ │ │ ├── animations/ │ │ │ │ │ └── SimpleLoader.tsx │ │ │ │ ├── connection-log/ │ │ │ │ │ ├── ConnectionLog.tsx │ │ │ │ │ └── ConnectionLogContext.tsx │ │ │ │ ├── dialogs/ │ │ │ │ │ ├── HostKeyVerificationDialog.tsx │ │ │ │ │ ├── OPKSSHDialog.tsx │ │ │ │ │ ├── QuickConnectDialog.tsx │ │ │ │ │ ├── SSHAuthDialog.tsx │ │ │ │ │ ├── TOTPDialog.tsx │ │ │ │ │ └── WarpgateDialog.tsx │ │ │ │ ├── hosts/ │ │ │ │ │ ├── FolderCard.tsx │ │ │ │ │ └── Host.tsx │ │ │ │ └── tabs/ │ │ │ │ ├── Tab.tsx │ │ │ │ ├── TabContext.tsx │ │ │ │ └── TabDropdown.tsx │ │ │ └── user/ │ │ │ ├── ElectronVersionCheck.tsx │ │ │ ├── LanguageSwitcher.tsx │ │ │ ├── PasswordReset.tsx │ │ │ ├── TOTPSetup.tsx │ │ │ └── UserProfile.tsx │ │ ├── hooks/ │ │ │ ├── useCommandHistory.ts │ │ │ ├── useCommandTracker.ts │ │ │ ├── useDragToDesktop.ts │ │ │ └── useDragToSystemDesktop.ts │ │ ├── main-axios.ts │ │ └── mobile/ │ │ ├── MobileApp.tsx │ │ ├── apps/ │ │ │ ├── navigation/ │ │ │ │ ├── BottomNavbar.tsx │ │ │ │ ├── LeftSidebar.tsx │ │ │ │ ├── hosts/ │ │ │ │ │ ├── FolderCard.tsx │ │ │ │ │ └── Host.tsx │ │ │ │ └── tabs/ │ │ │ │ └── TabContext.tsx │ │ │ └── terminal/ │ │ │ ├── Terminal.tsx │ │ │ ├── TerminalKeyboard.tsx │ │ │ ├── kb-dark-theme.css │ │ │ └── kb-light-theme.css │ │ ├── authentication/ │ │ │ └── Auth.tsx │ │ └── navigation/ │ │ ├── BottomNavbar.tsx │ │ ├── LeftSidebar.tsx │ │ ├── hosts/ │ │ │ ├── FolderCard.tsx │ │ │ └── Host.tsx │ │ └── tabs/ │ │ └── TabContext.tsx │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .commitlintrc.json ================================================ { "extends": ["@commitlint/config-conventional"], "rules": { "type-enum": [ 2, "always", [ "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "revert" ] ], "subject-case": [0] } } ================================================ FILE: .dockerignore ================================================ node_modules npm-debug.log* yarn-debug.log* yarn-error.log* dist build .next .nuxt .env.local .env.development.local .env.test.local .env.production.local .vscode .idea *.swp *.swo *~ .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db .git .gitignore README.md CONTRIBUTING.md LICENSE repo-images/ uploads/ electron/ electron-builder.json *.log *.tmp *.temp logs *.log pids *.pid *.seed *.pid.lock coverage .nyc_output jspm_packages/ .npm .eslintcache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ .node_repl_history *.tgz .yarn-integrity .cache .parcel-cache .next .nuxt .vuepress/dist .serverless .fusebox/ .dynamodb/ .tern-port ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}] indent_style = space indent_size = 2 [*.md] trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.js text eol=lf *.jsx text eol=lf *.ts text eol=lf *.tsx text eol=lf *.json text eol=lf *.css text eol=lf *.scss text eol=lf *.html text eol=lf *.md text eol=lf *.yaml text eol=lf *.yml text eol=lf *.sh text eol=lf *.bash text eol=lf *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.svg binary *.woff binary *.woff2 binary *.ttf binary *.eot binary ================================================ FILE: .github/FUNDING.yml ================================================ github: [LukeGus] ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Support Center url: https://github.com/Termix-SSH/Support/issues about: Report any feature requests or bugs in the support center - name: Discord url: https://discord.gg/jVQGdvHDrf about: Official Termix Discord server for general discussion and quick support ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" groups: dev-patch-updates: dependency-type: "development" update-types: - "patch" dev-minor-updates: dependency-type: "development" update-types: - "minor" prod-patch-updates: dependency-type: "production" update-types: - "patch" prod-minor-updates: dependency-type: "production" update-types: - "minor" - package-ecosystem: "docker" directory: "/docker" schedule: interval: "daily" groups: patch-updates: update-types: - "patch" minor-updates: update-types: - "minor" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/pull_request_template.md ================================================ # Overview _Short summary of what this PR does_ - [ ] Added: ... - [ ] Updated: ... - [ ] Removed: ... - [ ] Fixed: ... # Changes Made _Detailed explanation of changes (if needed)_ - ... # Related Issues _Link any issues this PR addresses_ - Closes #ISSUE_NUMBER - Related to #ISSUE_NUMBER # Screenshots / Demos _(Optional: add before/after screenshots, GIFs, or console output)_ # Checklist - [ ] Code follows project style guidelines - [ ] Supports mobile and desktop UI/app (if applicable) - [ ] I have read [Contributing.md](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) - [ ] This is not a translation request. See [docs](https://docs.termix.site/translations) ================================================ FILE: .github/workflows/docker.yml ================================================ name: Build and Push Docker Image on: workflow_dispatch: inputs: version: description: "Version to build (e.g., 1.8.0)" required: true build_type: description: "Build type" required: true default: "Development" type: choice options: - Development - Production jobs: build: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 with: platforms: linux/amd64,linux/arm64,linux/arm/v7 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Determine tags id: tags run: | VERSION=${{ github.event.inputs.version }} BUILD_TYPE=${{ github.event.inputs.build_type }} TAGS=() ALL_TAGS=() if [ "$BUILD_TYPE" = "Production" ]; then TAGS+=("release-$VERSION" "latest") for tag in "${TAGS[@]}"; do ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag") done else TAGS+=("dev-$VERSION") for tag in "${TAGS[@]}"; do ALL_TAGS+=("ghcr.io/lukegus/termix:$tag") done fi echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: lukegus password: ${{ secrets.GHCR_TOKEN }} - name: Login to Docker Hub (prod only) if: ${{ github.event.inputs.build_type == 'Production' }} uses: docker/login-action@v3 with: username: bugattiguy527 password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push multi-arch image uses: docker/build-push-action@v5 with: context: . file: ./docker/Dockerfile push: true platforms: linux/amd64,linux/arm64,linux/arm/v7 tags: ${{ env.ALL_TAGS }} build-args: | BUILDKIT_INLINE_CACHE=1 BUILDKIT_CONTEXT_KEEP_GIT_DIR=1 labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.created=${{ github.run_id }} outputs: type=registry,compression=gzip,compression-level=9 - name: Cleanup Docker if: always() run: | docker image prune -af docker system prune -af --volumes ================================================ FILE: .github/workflows/electron.yml ================================================ name: Build and Push Electron App on: workflow_dispatch: inputs: build_type: description: "Platform to build for" required: true default: "all" type: choice options: - all - windows - linux - macos artifact_destination: description: "What to do with the built app" required: true default: "file" type: choice options: - none - file - release - submit jobs: build-windows: runs-on: windows-latest if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit' permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: | $maxAttempts = 3 $attempt = 1 while ($attempt -le $maxAttempts) { try { npm ci break } catch { if ($attempt -eq $maxAttempts) { Write-Error "npm ci failed after $maxAttempts attempts" exit 1 } Start-Sleep -Seconds 10 $attempt++ } } - name: Get version id: package-version run: | $VERSION = (Get-Content package.json | ConvertFrom-Json).version echo "version=$VERSION" >> $env:GITHUB_OUTPUT - name: Build Windows (All Architectures) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm run build && npx electron-builder --win --x64 --ia32 - name: Upload Windows x64 NSIS Installer uses: actions/upload-artifact@v4 if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_x64_nsis path: release/termix_windows_x64_nsis.exe retention-days: 30 - name: Upload Windows ia32 NSIS Installer uses: actions/upload-artifact@v4 if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_ia32_nsis path: release/termix_windows_ia32_nsis.exe retention-days: 30 - name: Upload Windows x64 MSI Installer uses: actions/upload-artifact@v4 if: hashFiles('release/termix_windows_x64_msi.msi') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_x64_msi path: release/termix_windows_x64_msi.msi retention-days: 30 - name: Upload Windows ia32 MSI Installer uses: actions/upload-artifact@v4 if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_ia32_msi path: release/termix_windows_ia32_msi.msi retention-days: 30 - name: Create Windows x64 Portable zip if: hashFiles('release/win-unpacked/*') != '' run: | Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "termix_windows_x64_portable.zip" - name: Create Windows ia32 Portable zip if: hashFiles('release/win-ia32-unpacked/*') != '' run: | Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip" - name: Upload Windows x64 Portable uses: actions/upload-artifact@v4 if: hashFiles('termix_windows_x64_portable.zip') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_x64_portable path: termix_windows_x64_portable.zip retention-days: 30 - name: Upload Windows ia32 Portable uses: actions/upload-artifact@v4 if: hashFiles('termix_windows_ia32_portable.zip') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_windows_ia32_portable path: termix_windows_ia32_portable.zip retention-days: 30 build-linux: runs-on: blacksmith-4vcpu-ubuntu-2404 if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit' permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install system dependencies for AppImage run: | sudo apt-get update sudo apt-get install -y libfuse2 - name: Install dependencies run: | for i in 1 2 3; do if npm ci; then break else if [ $i -eq 3 ]; then exit 1 fi sleep 10 fi done npm install --force @rollup/rollup-linux-x64-gnu npm install --force @rollup/rollup-linux-arm64-gnu npm install --force @rollup/rollup-linux-arm-gnueabihf - name: Build Linux x64 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEBUG: electron-builder run: npm run build && npx electron-builder --linux --x64 - name: Build Linux arm64 and armv7l env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx electron-builder --linux --arm64 --armv7l - name: Rename Linux artifacts for consistency run: | cd release if [ -f "termix_linux_amd64_deb.deb" ]; then mv "termix_linux_amd64_deb.deb" "termix_linux_x64_deb.deb" fi if [ -f "termix_linux_x86_64_appimage.AppImage" ]; then mv "termix_linux_x86_64_appimage.AppImage" "termix_linux_x64_appimage.AppImage" fi cd .. - name: Upload Linux x64 AppImage uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_x64_appimage path: release/termix_linux_x64_appimage.AppImage retention-days: 30 - name: Upload Linux arm64 AppImage uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_arm64_appimage path: release/termix_linux_arm64_appimage.AppImage retention-days: 30 - name: Upload Linux armv7l AppImage uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_armv7l_appimage path: release/termix_linux_armv7l_appimage.AppImage retention-days: 30 - name: Upload Linux x64 DEB uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_x64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_x64_deb path: release/termix_linux_x64_deb.deb retention-days: 30 - name: Upload Linux arm64 DEB uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_arm64_deb path: release/termix_linux_arm64_deb.deb retention-days: 30 - name: Upload Linux armv7l DEB uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_armv7l_deb path: release/termix_linux_armv7l_deb.deb retention-days: 30 - name: Upload Linux x64 tar.gz uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_x64_portable path: release/termix_linux_x64_portable.tar.gz retention-days: 30 - name: Upload Linux arm64 tar.gz uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_arm64_portable path: release/termix_linux_arm64_portable.tar.gz retention-days: 30 - name: Upload Linux armv7l tar.gz uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_armv7l_portable path: release/termix_linux_armv7l_portable.tar.gz retention-days: 30 - name: Install Flatpak builder and dependencies run: | sudo apt-get update sudo apt-get install -y flatpak flatpak-builder imagemagick - name: Add Flathub repository run: | sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - name: Install Flatpak runtime and SDK run: | sudo flatpak install -y flathub org.freedesktop.Platform//24.08 sudo flatpak install -y flathub org.freedesktop.Sdk//24.08 sudo flatpak install -y flathub org.electronjs.Electron2.BaseApp//24.08 - name: Get version for Flatpak id: flatpak-version run: | VERSION=$(node -p "require('./package.json').version") RELEASE_DATE=$(date +%Y-%m-%d) echo "version=$VERSION" >> $GITHUB_OUTPUT echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT - name: Prepare Flatpak files run: | VERSION="${{ steps.flatpak-version.outputs.version }}" RELEASE_DATE="${{ steps.flatpak-version.outputs.release_date }}" CHECKSUM_X64=$(sha256sum "release/termix_linux_x64_appimage.AppImage" | awk '{print $1}') CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}') mkdir -p flatpak-build cp flatpak/com.karmaa.termix.yml flatpak-build/ cp flatpak/com.karmaa.termix.desktop flatpak-build/ cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/ cp public/icon.svg flatpak-build/com.karmaa.termix.svg convert public/icon.png -resize 256x256 flatpak-build/icon-256.png convert public/icon.png -resize 128x128 flatpak-build/icon-128.png cd flatpak-build sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" com.karmaa.termix.metainfo.xml - name: Build Flatpak bundle run: | cd flatpak-build flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml ARCH=$(uname -m) if [ "$ARCH" = "x86_64" ]; then FLATPAK_ARCH="x86_64" elif [ "$ARCH" = "aarch64" ]; then FLATPAK_ARCH="aarch64" else FLATPAK_ARCH="$ARCH" fi flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo - name: Create flatpakref file run: | VERSION="${{ steps.flatpak-version.outputs.version }}" cp flatpak/com.karmaa.termix.flatpakref release/ sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref - name: Upload Flatpak bundle uses: actions/upload-artifact@v4 if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_flatpak path: release/termix_linux_flatpak.flatpak retention-days: 30 - name: Upload Flatpakref uses: actions/upload-artifact@v4 if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_linux_flatpakref path: release/com.karmaa.termix.flatpakref retention-days: 30 build-macos: runs-on: macos-latest if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit' needs: [] permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: | for i in 1 2 3; do if npm ci; then break else if [ $i -eq 3 ]; then exit 1 fi sleep 10 fi done npm install --force @rollup/rollup-darwin-arm64 npm install dmg-license - name: Check for Code Signing Certificates id: check_certs run: | if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then echo "has_certs=true" >> $GITHUB_OUTPUT fi - name: Import Code Signing Certificates if: steps.check_certs.outputs.has_certs == 'true' env: MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }} MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }} MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }} MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }} run: | APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12 INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH fi security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH if [ -f "$INSTALLER_CERT_PATH" ]; then security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH fi security list-keychain -d user -s $KEYCHAIN_PATH security find-identity -v -p codesigning $KEYCHAIN_PATH - name: Build macOS App Store Package if: steps.check_certs.outputs.has_certs == 'true' env: ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | CURRENT_VERSION=$(node -p "require('./package.json').version") BUILD_VERSION="${{ github.run_number }}" npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" - name: Clean up MAS keychain before DMG build if: steps.check_certs.outputs.has_certs == 'true' run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true - name: Check for Developer ID Certificates id: check_dev_id_certs run: | if [ -n "${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.DEVELOPER_ID_P12_PASSWORD }}" ]; then echo "has_dev_id_certs=true" >> $GITHUB_OUTPUT fi - name: Import Developer ID Certificates if: steps.check_dev_id_certs.outputs.has_dev_id_certs == 'true' env: DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }} DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64 }} DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }} run: | DEV_CERT_PATH=$RUNNER_TEMP/dev_certificate.p12 DEV_INSTALLER_CERT_PATH=$RUNNER_TEMP/dev_installer_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/dev-signing.keychain-db echo -n "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode -o $DEV_CERT_PATH if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" ]; then echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $DEV_INSTALLER_CERT_PATH fi security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $DEV_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH if [ -f "$DEV_INSTALLER_CERT_PATH" ]; then security import $DEV_INSTALLER_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH fi security list-keychain -d user -s $KEYCHAIN_PATH security find-identity -v -p codesigning $KEYCHAIN_PATH - name: Build macOS DMG env: ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then npm run build fi export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" npx electron-builder --mac dmg --universal --x64 --arm64 --publish never - name: Upload macOS MAS PKG if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit') uses: actions/upload-artifact@v4 with: name: termix_macos_universal_mas path: release/termix_macos_universal_mas.pkg retention-days: 30 if-no-files-found: warn - name: Upload macOS Universal DMG uses: actions/upload-artifact@v4 if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_macos_universal_dmg path: release/termix_macos_universal_dmg.dmg retention-days: 30 - name: Upload macOS x64 DMG uses: actions/upload-artifact@v4 if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_macos_x64_dmg path: release/termix_macos_x64_dmg.dmg retention-days: 30 - name: Upload macOS arm64 DMG uses: actions/upload-artifact@v4 if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none' with: name: termix_macos_arm64_dmg path: release/termix_macos_arm64_dmg.dmg retention-days: 30 - name: Get version for Homebrew id: homebrew-version run: | VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Generate Homebrew Cask if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release') run: | VERSION="${{ steps.homebrew-version.outputs.version }}" DMG_PATH="release/termix_macos_universal_dmg.dmg" CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}') mkdir -p homebrew-generated cp Casks/termix.rb homebrew-generated/termix.rb sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb - name: Upload Homebrew Cask as artifact uses: actions/upload-artifact@v4 if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file' with: name: termix_macos_homebrew_cask path: homebrew-generated/termix.rb retention-days: 30 - name: Upload Homebrew Cask to release if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.homebrew-version.outputs.version }}" RELEASE_TAG="release-$VERSION-tag" gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || { echo "Release $RELEASE_TAG not found" exit 1 } gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber - name: Clean up keychains if: always() run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true security delete-keychain $RUNNER_TEMP/dev-signing.keychain-db || true submit-to-chocolatey: runs-on: windows-latest if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') permissions: contents: read steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get version from package.json id: package-version run: | $VERSION = (Get-Content package.json | ConvertFrom-Json).version echo "version=$VERSION" >> $env:GITHUB_OUTPUT - name: Download and prepare MSI info from public release id: msi-info run: | $VERSION = "${{ steps.package-version.outputs.version }}" $MSI_NAME = "termix_windows_x64_msi.msi" $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)" Write-Host "Downloading from $DOWNLOAD_URL" New-Item -ItemType Directory -Force -Path "release_asset" $DOWNLOAD_PATH = "release_asset\$MSI_NAME" try { Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $DOWNLOAD_PATH -UseBasicParsing } catch { Write-Error "Failed to download MSI from $DOWNLOAD_URL. Please ensure the release and asset exist." exit 1 } $CHECKSUM = (Get-FileHash -Path $DOWNLOAD_PATH -Algorithm SHA256).Hash echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT - name: Prepare Chocolatey package run: | $VERSION = "${{ steps.package-version.outputs.version }}" $CHECKSUM = "${{ steps.msi-info.outputs.checksum }}" $MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}" $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME" New-Item -ItemType Directory -Force -Path "choco-build" Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force $installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8 $installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL $installScript = $installScript -replace 'CHECKSUM_PLACEHOLDER', $CHECKSUM [System.IO.File]::WriteAllText("$PWD\choco-build\tools\chocolateyinstall.ps1", $installScript, [System.Text.UTF8Encoding]::new($false)) $nuspec = Get-Content "choco-build\termix-ssh.nuspec" -Raw -Encoding UTF8 $nuspec = $nuspec -replace 'VERSION_PLACEHOLDER', $VERSION [System.IO.File]::WriteAllText("$PWD\choco-build\termix-ssh.nuspec", $nuspec, [System.Text.UTF8Encoding]::new($false)) - name: Install Chocolatey run: | Set-ExecutionPolicy Bypass -Scope Process -Force [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - name: Pack Chocolatey package run: | cd choco-build choco pack termix-ssh.nuspec if ($LASTEXITCODE -ne 0) { throw "Chocolatey push failed with exit code $LASTEXITCODE" } - name: Check for Chocolatey API Key id: check_choco_key run: | if ("${{ secrets.CHOCOLATEY_API_KEY }}" -ne "") { echo "has_key=true" >> $env:GITHUB_OUTPUT } - name: Push to Chocolatey if: steps.check_choco_key.outputs.has_key == 'true' run: | $VERSION = "${{ steps.package-version.outputs.version }}" cd choco-build choco apikey --key "${{ secrets.CHOCOLATEY_API_KEY }}" --source https://push.chocolatey.org/ try { choco push "termix-ssh.$VERSION.nupkg" --source https://push.chocolatey.org/ if ($LASTEXITCODE -eq 0) { } else { throw "Chocolatey push failed with exit code $LASTEXITCODE" } } catch { } - name: Upload Chocolatey package as artifact uses: actions/upload-artifact@v4 with: name: chocolatey-package path: choco-build/*.nupkg retention-days: 30 submit-to-flatpak: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') needs: [] permissions: contents: read steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get version from package.json id: package-version run: | VERSION=$(node -p "require('./package.json').version") RELEASE_DATE=$(date +%Y-%m-%d) echo "version=$VERSION" >> $GITHUB_OUTPUT echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT - name: Download and prepare AppImage info from public release id: appimage-info run: | VERSION="${{ steps.package-version.outputs.version }}" mkdir -p release_assets APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage" URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME" PATH_X64="release_assets/$APPIMAGE_X64_NAME" echo "Downloading x64 AppImage from $URL_X64" curl -L -o "$PATH_X64" "$URL_X64" chmod +x "$PATH_X64" CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}') APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage" URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME" PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME" echo "Downloading arm64 AppImage from $URL_ARM64" curl -L -o "$PATH_ARM64" "$URL_ARM64" chmod +x "$PATH_ARM64" CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}') echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT - name: Install ImageMagick for icon generation run: | sudo apt-get update sudo apt-get install -y imagemagick - name: Prepare Flatpak submission files run: | VERSION="${{ steps.package-version.outputs.version }}" CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}" CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}" RELEASE_DATE="${{ steps.package-version.outputs.release_date }}" APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}" APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}" mkdir -p flatpak-submission cp flatpak/com.karmaa.termix.yml flatpak-submission/ cp flatpak/com.karmaa.termix.desktop flatpak-submission/ cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/ cp flatpak/flathub.json flatpak-submission/ cp public/icon.svg flatpak-submission/com.karmaa.termix.svg convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml - name: Upload Flatpak submission as artifact uses: actions/upload-artifact@v4 with: name: flatpak-submission path: flatpak-submission/* retention-days: 30 submit-to-homebrew: runs-on: macos-latest if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos') needs: [] permissions: contents: read steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Get version from package.json id: package-version run: | VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Download and prepare DMG info from public release id: dmg-info run: | VERSION="${{ steps.package-version.outputs.version }}" DMG_NAME="termix_macos_universal_dmg.dmg" URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME" mkdir -p release_asset DOWNLOAD_PATH="release_asset/$DMG_NAME" echo "Downloading DMG from $URL" if command -v curl &> /dev/null; then curl -L -o "$DOWNLOAD_PATH" "$URL" elif command -v wget &> /dev/null; then wget -O "$DOWNLOAD_PATH" "$URL" else echo "Neither curl nor wget is available, installing curl" brew install curl curl -L -o "$DOWNLOAD_PATH" "$URL" fi CHECKSUM=$(shasum -a 256 "$DOWNLOAD_PATH" | awk '{print $1}') echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT - name: Prepare Homebrew submission files run: | VERSION="${{ steps.package-version.outputs.version }}" CHECKSUM="${{ steps.dmg-info.outputs.checksum }}" DMG_NAME="${{ steps.dmg-info.outputs.dmg_name }}" mkdir -p homebrew-submission/Casks/t cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb - name: Verify Cask syntax run: | ruby -c homebrew-submission/Casks/t/termix.rb - name: Upload Homebrew submission as artifact uses: actions/upload-artifact@v4 with: name: homebrew-submission path: homebrew-submission/* retention-days: 30 upload-to-release: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.event.inputs.artifact_destination == 'release' needs: [build-windows, build-linux, build-macos] permissions: contents: write steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Get latest release tag id: get_release run: | echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV env: GH_TOKEN: ${{ github.token }} - name: Upload artifacts to latest release run: | cd artifacts for dir in */; do cd "$dir" for file in *; do if [ -f "$file" ]; then gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber fi done cd .. done env: GH_TOKEN: ${{ github.token }} submit-to-testflight: runs-on: macos-latest if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos') needs: [] permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: | for i in 1 2 3; do if npm ci; then break else if [ $i -eq 3 ]; then exit 1 fi sleep 10 fi done npm install --force @rollup/rollup-darwin-arm64 npm install dmg-license - name: Check for Code Signing Certificates id: check_certs run: | if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then echo "has_certs=true" >> $GITHUB_OUTPUT fi - name: Import Code Signing Certificates if: steps.check_certs.outputs.has_certs == 'true' env: MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }} MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }} MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }} MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }} run: | APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12 INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12 KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH fi security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH if [ -f "$INSTALLER_CERT_PATH" ]; then security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH fi security list-keychain -d user -s $KEYCHAIN_PATH security find-identity -v -p codesigning $KEYCHAIN_PATH - name: Build macOS App Store Package if: steps.check_certs.outputs.has_certs == 'true' env: ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | CURRENT_VERSION=$(node -p "require('./package.json').version") BUILD_VERSION="${{ github.run_number }}" npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION" - name: Check for App Store Connect API credentials id: check_asc_creds run: | if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then echo "has_credentials=true" >> $GITHUB_OUTPUT fi - name: Setup Ruby for Fastlane if: steps.check_asc_creds.outputs.has_credentials == 'true' uses: ruby/setup-ruby@v1 with: ruby-version: "3.2" bundler-cache: false - name: Install Fastlane if: steps.check_asc_creds.outputs.has_credentials == 'true' run: | gem install fastlane -N - name: Deploy to App Store Connect (TestFlight) if: steps.check_asc_creds.outputs.has_credentials == 'true' run: | PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1) if [ -z "$PKG_FILE" ]; then echo "PKG file not found, exiting." exit 1 fi mkdir -p ~/private_keys echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8 xcrun altool --upload-app -f "$PKG_FILE" \ --type macos \ --apiKey "${{ secrets.APPLE_KEY_ID }}" \ --apiIssuer "${{ secrets.APPLE_ISSUER_ID }}" continue-on-error: true - name: Clean up keychains if: always() run: | security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true ================================================ FILE: .github/workflows/openapi.yml ================================================ name: Generate OpenAPI Specification on: workflow_dispatch: jobs: generate-openapi: name: Generate OpenAPI JSON runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Generate OpenAPI specification run: npm run generate:openapi - name: Upload OpenAPI artifact uses: actions/upload-artifact@v4 with: name: openapi-spec path: openapi.json retention-days: 90 ================================================ FILE: .github/workflows/pr-check.yml ================================================ name: PR Check on: pull_request: branches: [main, dev-*] jobs: lint-and-build: runs-on: blacksmith-2vcpu-ubuntu-2404 env: NODE_OPTIONS: "--max-old-space-size=4096" steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" - name: Install dependencies run: | rm -rf node_modules package-lock.json npm install - name: Run ESLint run: npx eslint . - name: Run Prettier check run: npx prettier --check . - name: Type check run: npx tsc --noEmit - name: Build run: npm run build ================================================ FILE: .gitignore ================================================ logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? /db/ /release/ /.claude/ /ssl/ .env /.mcp.json /nul /.vscode/ /CLAUDE.md ================================================ FILE: .husky/commit-msg ================================================ npx --no -- commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .nvmrc ================================================ 20 ================================================ FILE: .prettierignore ================================================ build coverage dist dist-ssr release node_modules package-lock.json pnpm-lock.yaml yarn.lock db .env *.min.js *.min.css openapi.json ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": false, "tabWidth": 2, "trailingComma": "all", "printWidth": 80, "arrowParens": "always", "endOfLine": "lf" } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at mail@termix.site. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing ## Prerequisites - [Node.js](https://nodejs.org/en/download/) (built with v24) - [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) - [Git](https://git-scm.com/downloads) ## Installation 1. Clone the repository: ```sh git clone https://github.com/Termix-SSH/Termix ``` 2. Install the dependencies: ```sh npm install ``` ## Running the development server Run the following commands: ```sh npm run dev npm run dev:backend ``` This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`. ## Contributing 1. **Fork the repository**: Click the "Fork" button at the top right of the [repository page](https://github.com/Termix-SSH/Termix). 2. **Create a new branch**: ```sh git checkout -b feature/my-new-feature ``` 3. **Make your changes**: Implement your feature, fix, or improvement. 4. **Commit your changes**: ```sh git commit -m "Feature request my new feature" ``` 5. **Push to your fork**: ```sh git push origin feature/my-feature-request ``` 6. **Open a pull request**: Go to the original repository and create a PR with a clear description. ## Guidelines - Follow the existing code style. Use Tailwind CSS with shadcn components. - Use the below color scheme with the respective CSS variable placed in the `className` of a div/component. - Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded. - Include meaningful commit messages. - Link related issues when applicable. - `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`. ## Color Scheme ### Background Colors | CSS Variable | Color Value | Usage | Description | | ----------------------------- | ----------- | --------------------------- | ---------------------------------------- | | `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color | | `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers | | `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) | | `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background | | `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background | | `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards | | `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover | ### Element-Specific Backgrounds | CSS Variable | Color Value | Usage | Description | | ------------------------ | ----------- | ------------------ | --------------------------------------------- | | `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements | | `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements | | `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements | | `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars | ### Border Colors | CSS Variable | Color Value | Usage | Description | | ---------------------------- | ----------- | --------------- | ---------------------------------------- | | `--color-dark-border` | `#303032` | Default borders | Standard border color | | `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements | | `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states | | `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements | | `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color | | `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards | ### Interactive States | CSS Variable | Color Value | Usage | Description | | ------------------------ | ----------- | ----------------- | --------------------------------------------- | | `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects | | `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements | | `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements | | `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color | ## Support If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel, however, response times may be longer. ================================================ FILE: Casks/termix.rb ================================================ cask "termix" do version "2.0.0" sha256 "a752ad4f05b4991b8a2ef986da80a86b79a70c93fe1045c4399fcf348a28c1d0" url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg" name "Termix" desc "Web-based server management platform with SSH terminal, tunneling, and file editing" homepage "https://github.com/Termix-SSH/Termix" livecheck do url :url strategy :github_latest end app "Termix.app" zap trash: [ "~/Library/Application Support/termix", "~/Library/Caches/com.karmaa.termix", "~/Library/Caches/com.karmaa.termix.ShipIt", "~/Library/Preferences/com.karmaa.termix.plist", "~/Library/Saved Application State/com.karmaa.termix.savedState", ] end ================================================ FILE: LICENSE ================================================ Copyright 2025 Luke Gustafson Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Repo Stats

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Achieved on September 1st, 2025


Termix Banner

If you would like, you can support the project here!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Overview

Termix Banner

Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote SSH file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms. # Features - **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components. - **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening - **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections - **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. - **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. - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys - **Server Stats** - View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers - **Dashboard** - View server information at a glance on your dashboard - **RBAC** - Create roles and share hosts across users/roles - **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. - **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI. Use URL routes to open any connection in full-screen. - **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)) - **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android. - **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. - **Command History** - Auto-complete and view previously ran SSH commands - **Quick Connect** - Connect to a server without having to save the connection data - **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard - **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), etc. - **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support - **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile # Planned Features See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Installation Supported Devices: - Website (any modern browser on any platform like Chrome, Safari, and Firefox) (includes PWA support) - Windows (x64/ia32) - Portable - MSI Installer - Chocolatey Package Manager - Linux (x64/ia32) - Portable - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 on v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view a sample Docker Compose file here (you can omit guacd and the network if you don't plan on using remote desktop features): ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Sponsors

DigitalOcean          Crowdin          Crowdin          Crowdin

# Support If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel, however, response times may be longer. # Screenshots [![YouTube](./repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Some videos and images may be out of date or may not perfectly showcase features. # License Distributed under the Apache License Version 2.0. See LICENSE for more information. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories). ================================================ FILE: build/entitlements.mac.inherit.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.cs.allow-dyld-environment-variables ================================================ FILE: build/entitlements.mac.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.cs.allow-dyld-environment-variables ================================================ FILE: build/entitlements.mas.inherit.plist ================================================ com.apple.security.app-sandbox com.apple.security.inherit com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ================================================ FILE: build/entitlements.mas.plist ================================================ com.apple.security.app-sandbox com.apple.security.network.client com.apple.security.network.server com.apple.security.files.user-selected.read-write com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation ================================================ FILE: build/notarize.cjs ================================================ const { notarize } = require('@electron/notarize'); exports.default = async function notarizing(context) { const { electronPlatformName, appOutDir } = context; if (electronPlatformName !== 'darwin') { return; } const appleId = process.env.APPLE_ID; const appleIdPassword = process.env.APPLE_ID_PASSWORD; const teamId = process.env.APPLE_TEAM_ID; if (!appleId || !appleIdPassword || !teamId) { return; } const appName = context.packager.appInfo.productFilename; try { await notarize({ appBundleId: 'com.karmaa.termix', appPath: `${appOutDir}/${appName}.app`, appleId: appleId, appleIdPassword: appleIdPassword, teamId: teamId, }); } catch (error) { console.error('Notarization failed:', error); } }; ================================================ FILE: chocolatey/termix-ssh.nuspec ================================================ termix-ssh VERSION_PLACEHOLDER https://github.com/Termix-SSH/Termix bugattiguy527 Termix SSH bugattiguy527 https://github.com/Termix-SSH/Termix https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png https://raw.githubusercontent.com/Termix-SSH/Termix/refs/heads/main/LICENSE false https://github.com/Termix-SSH/Termix https://docs.termix.site/install https://github.com/Termix-SSH/Support/issues docker ssh self-hosted file-management ssh-tunnel termix server-management terminal Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities. Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers: - SSH terminal access - SSH tunneling capabilities - Remote file management - Server monitoring and management This package installs the desktop application version of Termix. https://github.com/Termix-SSH/Termix/releases ================================================ FILE: chocolatey/tools/chocolateyinstall.ps1 ================================================ $ErrorActionPreference = 'Stop' $packageName = 'termix-ssh' $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" $url64 = 'DOWNLOAD_URL_PLACEHOLDER' $checksum64 = 'CHECKSUM_PLACEHOLDER' $checksumType64 = 'sha256' $packageArgs = @{ packageName = $packageName fileType = 'msi' url64bit = $url64 softwareName = 'Termix*' checksum64 = $checksum64 checksumType64 = $checksumType64 silentArgs = "/qn /norestart /l*v `"$($env:TEMP)\$($packageName).$($env:chocolateyPackageVersion).MsiInstall.log`"" validExitCodes = @(0, 3010, 1641) } Install-ChocolateyPackage @packageArgs ================================================ FILE: chocolatey/tools/chocolateyuninstall.ps1 ================================================ $ErrorActionPreference = 'Stop' $packageName = 'termix-ssh' $softwareName = 'Termix*' $installerType = 'msi' $silentArgs = '/qn /norestart' $validExitCodes = @(0, 3010, 1605, 1614, 1641) [array]$key = Get-UninstallRegistryKey -SoftwareName $softwareName if ($key.Count -eq 1) { $key | % { $file = "$($_.UninstallString)" if ($installerType -eq 'msi') { $silentArgs = "$($_.PSChildName) $silentArgs" $file = '' } Uninstall-ChocolateyPackage -PackageName $packageName ` -FileType $installerType ` -SilentArgs "$silentArgs" ` -ValidExitCodes $validExitCodes ` -File "$file" } } elseif ($key.Count -eq 0) { Write-Warning "$packageName has already been uninstalled by other means." } elseif ($key.Count -gt 1) { Write-Warning "$($key.Count) matches found!" Write-Warning "To prevent accidental data loss, no programs will be uninstalled." $key | % {Write-Warning "- $($_.DisplayName)"} } ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "", "css": "src/index.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: crowdin.yml ================================================ files: - source: /src/locales/en.json translation: /src/locales/translated/%locale_with_underscore%.json ================================================ FILE: docker/Dockerfile ================================================ # Stage 1: Install dependencies FROM node:22-slim AS deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY package*.json ./ RUN npm ci --ignore-scripts --force && \ npm cache clean --force # Stage 2: Build frontend FROM deps AS frontend-builder WORKDIR /app COPY . . RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete RUN npm cache clean --force && \ NODE_OPTIONS="--max-old-space-size=3072" npm run build # Stage 3: Build backend FROM deps AS backend-builder WORKDIR /app COPY . . RUN npm rebuild better-sqlite3 --force RUN npm run build:backend # Stage 4: Production dependencies only FROM node:22-slim AS production-deps WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY package*.json ./ RUN npm ci --only=production --ignore-scripts --force && \ npm rebuild better-sqlite3 bcryptjs --force && \ npm cache clean --force # Stage 5: Final optimized image FROM node:22-slim WORKDIR /app ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \ chown -R node:node /app && \ chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx && \ touch /app/nginx/nginx.conf && \ chown node:node /app/nginx/nginx.conf COPY docker/nginx.conf /app/nginx/nginx.conf.template COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template COPY --chown=node:node --from=frontend-builder /app/dist /app/html COPY --chown=node:node --from=frontend-builder /app/src/locales /app/html/locales COPY --chown=node:node --from=frontend-builder /app/public/fonts /app/html/fonts COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend COPY --chown=node:node package.json ./ VOLUME ["/app/data"] EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget -q -O /dev/null http://localhost:30001/health || exit 1 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh CMD ["/entrypoint.sh"] ================================================ FILE: docker/docker-compose.yml ================================================ services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ================================================ FILE: docker/entrypoint.sh ================================================ #!/bin/sh set -e PUID=${PUID:-1000} PGID=${PGID:-1000} if [ "$(id -u)" = "0" ]; then if [ "$PUID" = "0" ]; then echo "Running as root (PUID=0, PGID=$PGID)" chown -R root:root /app/data /app/uploads /app/nginx 2>/dev/null || true else echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..." 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 /app/nginx 2>/dev/null || true echo "User node is now UID: $PUID, GID: $PGID" exec gosu node:node "$0" "$@" fi fi export PORT=${PORT:-8080} export ENABLE_SSL=${ENABLE_SSL:-false} export SSL_PORT=${SSL_PORT:-8443} export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt} export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key} echo "Configuring web UI to run on port: $PORT" if [ "$ENABLE_SSL" = "true" ]; then echo "SSL enabled - using HTTPS configuration with redirect" NGINX_CONF_SOURCE="/app/nginx/nginx-https.conf.template" else echo "SSL disabled - using HTTP-only configuration (default)" NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template" fi envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /app/nginx/nginx.conf mkdir -p /app/data /app/uploads /app/data/.opk chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true if [ -w /app/data ]; then echo "Data directory is writable" else echo "WARNING: Data directory is not writable. OPKSSH may fail." ls -ld /app/data fi if [ -w /app/data/.opk ]; then echo "OPKSSH directory is writable" else echo "WARNING: OPKSSH directory is not writable. OPKSSH authentication will fail." ls -ld /app/data/.opk fi OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh" if [ ! -d "$OPKSSH_DIR" ]; then echo "WARNING: OPKSSH binary directory not found at $OPKSSH_DIR" echo "OPKSSH will be downloaded automatically on first use." else echo "OPKSSH binary directory found at $OPKSSH_DIR" fi if [ "$ENABLE_SSL" = "true" ]; then echo "Checking SSL certificate configuration..." mkdir -p /app/data/ssl chmod 755 /app/data/ssl 2>/dev/null || true DOMAIN=${SSL_DOMAIN:-localhost} if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then echo "SSL certificates found, checking validity..." if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then echo "SSL certificates are valid and will be reused for domain: $DOMAIN" else echo "SSL certificate is expired or expiring soon, regenerating..." rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key fi else echo "SSL certificates not found, will generate new ones..." fi if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then echo "Generating SSL certificates for domain: $DOMAIN" cat > /app/data/ssl/openssl.conf << EOF [req] default_bits = 2048 prompt = no default_md = sha256 distinguished_name = dn req_extensions = v3_req [dn] C=US ST=State L=City O=Termix OU=IT Department CN=$DOMAIN [v3_req] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = $DOMAIN DNS.2 = localhost DNS.3 = 127.0.0.1 IP.1 = 127.0.0.1 IP.2 = ::1 IP.3 = 0.0.0.0 EOF openssl genrsa -out /app/data/ssl/termix.key 2048 openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req chmod 600 /app/data/ssl/termix.key chmod 644 /app/data/ssl/termix.crt rm -f /app/data/ssl/openssl.conf echo "SSL certificates generated successfully for domain: $DOMAIN" fi fi echo "Starting nginx..." nginx -c /app/nginx/nginx.conf echo "Starting backend services..." cd /app export NODE_ENV=production if [ -f "package.json" ]; then VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/') if [ -n "$VERSION" ]; then export VERSION else echo "Warning: Could not extract version from package.json" fi else echo "Warning: package.json not found" fi node dist/backend/backend/starter.js echo "All services started" tail -f /dev/null ================================================ FILE: docker/nginx-https.conf ================================================ worker_processes 1; master_process off; pid /app/nginx/nginx.pid; error_log /app/nginx/logs/error.log warn; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; access_log /app/nginx/logs/access.log; client_body_temp_path /app/nginx/client_body; proxy_temp_path /app/nginx/proxy_temp; fastcgi_temp_path /app/nginx/fastcgi_temp; uwsgi_temp_path /app/nginx/uwsgi_temp; scgi_temp_path /app/nginx/scgi_temp; sendfile on; keepalive_timeout 65; client_header_timeout 300s; set_real_ip_from 127.0.0.1; real_ip_header X-Forwarded-For; map $http_x_forwarded_proto $proxy_x_forwarded_proto { default $http_x_forwarded_proto; '' $scheme; } map $http_x_forwarded_host $proxy_x_forwarded_host { default $http_x_forwarded_host; '' $http_host; } map $http_x_forwarded_port $proxy_x_forwarded_port { default $http_x_forwarded_port; '' ''; } ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; server { listen ${PORT}; server_name _; return 301 https://$host:${SSL_PORT}$request_uri; } server { listen ${SSL_PORT} ssl; server_name _; ssl_certificate ${SSL_CERT_PATH}; ssl_certificate_key ${SSL_KEY_PATH}; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { root /app/html; expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } location / { root /app/html; index index.html index.htm; try_files $uri $uri/ /index.html; } location ~* \.map$ { return 404; access_log off; log_not_found off; } location ~ ^/users/sessions(/.*)?$ { 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 ~ ^/users(/.*)?$ { 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; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; } location ~ ^/version(/.*)?$ { 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 ~ ^/releases(/.*)?$ { 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 ~ ^/alerts(/.*)?$ { 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 ~ ^/rbac(/.*)?$ { 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 ~ ^/credentials(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; } location ~ ^/snippets(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/terminal(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; 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 ~ ^/database(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/db(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/encryption(/.*)?$ { 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 /host/quick-connect { proxy_pass http://127.0.0.1:30001; 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 $scheme; } location ~ ^/host/opkssh-chooser(/.*)?$ { proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 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_cache_bypass 1; proxy_no_cache 1; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location ~ ^/host/opkssh-callback(/.*)?$ { proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 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_cache_bypass 1; proxy_no_cache 1; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location /host/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /ssh/websocket/ { proxy_pass http://127.0.0.1:30002/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ^~ /guacamole/websocket/ { proxy_pass http://127.0.0.1:30008/; 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 $scheme; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ~ ^/guacamole(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $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 /host/tunnel/ { proxy_pass http://127.0.0.1:30003; 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 /host/file_manager/recent { 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 /host/file_manager/pinned { 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 /host/file_manager/shortcuts { 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 /host/file_manager/sudo-password { proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /ssh/file_manager/ { client_max_body_size 5G; client_body_timeout 300s; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location /host/file_manager/ssh/ { client_max_body_size 5G; client_body_timeout 300s; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/network-topology(/.*)?$ { 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 /health { 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 ~ ^/status(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/metrics(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } location ~ ^/global-settings(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/uptime(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/activity(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/dashboard/preferences(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; 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 $scheme; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ~ ^/docker(/.*)?$ { proxy_pass http://127.0.0.1:30007; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; } } } ================================================ FILE: docker/nginx.conf ================================================ worker_processes 1; master_process off; pid /app/nginx/nginx.pid; error_log /app/nginx/logs/error.log warn; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; access_log /app/nginx/logs/access.log; client_body_temp_path /app/nginx/client_body; proxy_temp_path /app/nginx/proxy_temp; fastcgi_temp_path /app/nginx/fastcgi_temp; uwsgi_temp_path /app/nginx/uwsgi_temp; scgi_temp_path /app/nginx/scgi_temp; sendfile on; keepalive_timeout 65; client_header_timeout 300s; set_real_ip_from 127.0.0.1; real_ip_header X-Forwarded-For; map $http_x_forwarded_proto $proxy_x_forwarded_proto { default $http_x_forwarded_proto; '' $scheme; } map $http_x_forwarded_host $proxy_x_forwarded_host { default $http_x_forwarded_host; '' $http_host; } map $http_x_forwarded_port $proxy_x_forwarded_port { default $http_x_forwarded_port; '' ''; } ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; server { listen ${PORT}; server_name localhost; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { root /app/html; expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; } location / { root /app/html; index index.html index.htm; try_files $uri $uri/ /index.html; } location ~* \.map$ { return 404; access_log off; log_not_found off; } location ~ ^/users/sessions(/.*)?$ { 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 ~ ^/users(/.*)?$ { 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; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; } location ~ ^/version(/.*)?$ { 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 ~ ^/releases(/.*)?$ { 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 ~ ^/alerts(/.*)?$ { 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 ~ ^/rbac(/.*)?$ { 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 ~ ^/credentials(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; } location ~ ^/snippets(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/terminal(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; 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 ~ ^/database(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/db(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/encryption(/.*)?$ { 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 /host/quick-connect { proxy_pass http://127.0.0.1:30001; 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 $scheme; } location ~ ^/host/opkssh-chooser(/.*)?$ { proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 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_cache_bypass 1; proxy_no_cache 1; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location ~ ^/host/opkssh-callback(/.*)?$ { proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 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_cache_bypass 1; proxy_no_cache 1; add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; } location /host/ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /ssh/websocket/ { proxy_pass http://127.0.0.1:30002/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host; proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ^~ /guacamole/websocket/ { proxy_pass http://127.0.0.1:30008/; 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 $scheme; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ~ ^/guacamole(/.*)?$ { proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $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 /host/tunnel/ { proxy_pass http://127.0.0.1:30003; 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 /host/file_manager/recent { 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 /host/file_manager/pinned { 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 /host/file_manager/shortcuts { 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 /host/file_manager/sudo-password { proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location /ssh/file_manager/ { client_max_body_size 5G; client_body_timeout 300s; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location /host/file_manager/ssh/ { client_max_body_size 5G; client_body_timeout 300s; add_header Cache-Control "no-store, no-cache, must-revalidate" always; proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; proxy_request_buffering off; proxy_buffering off; } location ~ ^/network-topology(/.*)?$ { 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 /health { 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 ~ ^/status(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/metrics(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } location ~ ^/global-settings(/.*)?$ { proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/uptime(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/activity(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ~ ^/dashboard/preferences(/.*)?$ { proxy_pass http://127.0.0.1:30006; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location ^~ /docker/console/ { proxy_pass http://127.0.0.1:30009/; proxy_http_version 1.1; 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 $scheme; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Forwarded-Host $http_host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; proxy_connect_timeout 10s; proxy_buffering off; proxy_request_buffering off; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } location ~ ^/docker(/.*)?$ { proxy_pass http://127.0.0.1:30007; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; } error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; } } } ================================================ FILE: electron/main.cjs ================================================ const { app, BrowserWindow, shell, ipcMain, dialog, Menu, Tray, } = require("electron"); const path = require("path"); const fs = require("fs"); const os = require("os"); const https = require("https"); const http = require("http"); const { URL } = require("url"); const { fork } = require("child_process"); const logFile = path.join(app.getPath("userData"), "termix-main.log"); function logToFile(...args) { const timestamp = new Date().toISOString(); const msg = args .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))) .join(" "); const line = `[${timestamp}] ${msg}\n`; try { fs.appendFileSync(logFile, line); } catch { // ignore } console.log(...args); } function httpFetch(url, options = {}) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const isHttps = urlObj.protocol === "https:"; const client = isHttps ? https : http; const requestOptions = { method: options.method || "GET", headers: options.headers || {}, timeout: options.timeout || 10000, }; if (isHttps) { requestOptions.rejectUnauthorized = false; requestOptions.agent = new https.Agent({ rejectUnauthorized: false, checkServerIdentity: () => undefined, }); } const req = client.request(url, requestOptions, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text: () => Promise.resolve(data), json: () => Promise.resolve(JSON.parse(data)), }); }); }); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); }); if (options.body) { req.write(options.body); } req.end(); }); } if (process.platform === "linux") { app.commandLine.appendSwitch("--ozone-platform-hint=auto"); app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder"); } app.commandLine.appendSwitch("--ignore-certificate-errors"); app.commandLine.appendSwitch("--ignore-ssl-errors"); app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list"); app.commandLine.appendSwitch("--enable-features=NetworkService"); let mainWindow = null; let backendProcess = null; let tray = null; let isQuitting = false; const isDev = process.env.NODE_ENV === "development" || !app.isPackaged; const appRoot = isDev ? process.cwd() : path.join(__dirname, ".."); function getBackendEntryPath() { if (isDev) { return path.join(appRoot, "dist", "backend", "backend", "starter.js"); } return path.join(appRoot, "dist", "backend", "backend", "starter.js"); } function getBackendDataDir() { const userDataPath = app.getPath("userData"); const dataDir = path.join(userDataPath, "server-data"); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } return dataDir; } function startBackendServer() { return new Promise((resolve) => { const entryPath = getBackendEntryPath(); logToFile("isDev:", isDev, "appRoot:", appRoot); logToFile("app.isPackaged:", app.isPackaged); logToFile("process.env.NODE_ENV:", process.env.NODE_ENV); if (!fs.existsSync(entryPath)) { logToFile("Backend entry not found:", entryPath); resolve(false); return; } const dataDir = getBackendDataDir(); logToFile("Starting embedded backend server..."); logToFile("Backend entry:", entryPath); logToFile("Data directory:", dataDir); logToFile("Backend cwd:", appRoot); logToFile("Checking paths..."); logToFile(" entryPath exists:", fs.existsSync(entryPath)); logToFile(" dataDir exists:", fs.existsSync(dataDir)); logToFile(" appRoot exists:", fs.existsSync(appRoot)); const distPath = path.join(appRoot, "dist"); if (fs.existsSync(distPath)) { logToFile(" dist directory contents:", fs.readdirSync(distPath)); const backendPath = path.join(distPath, "backend"); if (fs.existsSync(backendPath)) { logToFile(" dist/backend contents:", fs.readdirSync(backendPath)); } } backendProcess = fork(entryPath, [], { cwd: appRoot, env: { ...process.env, DATA_DIR: dataDir, NODE_ENV: "production", ELECTRON_EMBEDDED: "true", PORT: "30001", }, stdio: ["pipe", "pipe", "pipe", "ipc"], }); logToFile("Backend process spawned, pid:", backendProcess.pid); let resolved = false; const readyTimeout = setTimeout(() => { if (!resolved) { resolved = true; logToFile("Backend ready timeout (15s), proceeding anyway..."); resolve(true); } }, 15000); backendProcess.stdout.on("data", (data) => { const msg = data.toString().trim(); logToFile("[backend]", msg); if (!resolved && msg.includes("started successfully")) { resolved = true; clearTimeout(readyTimeout); logToFile("Backend ready signal received"); resolve(true); } }); backendProcess.stderr.on("data", (data) => { logToFile("[backend:stderr]", data.toString().trim()); }); backendProcess.on("exit", (code, signal) => { logToFile(`Backend process exited with code ${code}, signal ${signal}`); backendProcess = null; if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); backendProcess.on("error", (err) => { logToFile("Failed to start backend process:", err.message); backendProcess = null; if (!resolved) { resolved = true; clearTimeout(readyTimeout); resolve(false); } }); }); } function stopBackendServer() { if (!backendProcess) return; console.log("Stopping embedded backend server..."); try { backendProcess.send({ type: "shutdown" }); } catch { // IPC channel may already be closed } const forceKillTimeout = setTimeout(() => { if (backendProcess) { console.log("Force killing backend process..."); backendProcess.kill("SIGKILL"); backendProcess = null; } }, 5000); backendProcess.on("exit", () => { clearTimeout(forceKillTimeout); backendProcess = null; }); } const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { console.log("Another instance is already running, quitting..."); app.quit(); process.exit(0); } else { app.on("second-instance", (event, commandLine, workingDirectory) => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); mainWindow.show(); } }); } function createTray() { try { const { nativeImage } = require("electron"); let trayIcon; if (process.platform === "darwin") { const iconPath = path.join(appRoot, "public", "icons", "16x16.png"); trayIcon = nativeImage.createFromPath(iconPath); trayIcon.setTemplateImage(true); } else if (process.platform === "win32") { trayIcon = path.join(appRoot, "public", "icon.ico"); } else { trayIcon = path.join(appRoot, "public", "icons", "32x32.png"); } tray = new Tray(trayIcon); tray.setToolTip("Termix"); const contextMenu = Menu.buildFromTemplate([ { label: "Show Window", click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }, }, { label: "Quit", click: () => { isQuitting = true; app.quit(); }, }, ]); tray.setContextMenu(contextMenu); tray.on("click", () => { if (mainWindow) { if (mainWindow.isVisible()) { mainWindow.hide(); } else { mainWindow.show(); mainWindow.focus(); } } }); console.log("System tray created successfully"); } catch (err) { console.error("Failed to create system tray:", err); } } function createWindow() { const appVersion = app.getVersion(); const electronVersion = process.versions.electron; const platform = process.platform === "win32" ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, title: "Termix", icon: path.join(appRoot, "public", "icon.png"), webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: false, preload: path.join(__dirname, "preload.js"), partition: "persist:termix", allowRunningInsecureContent: true, webviewTag: true, offscreen: false, }, show: true, }); mainWindow.webContents.session.setPermissionRequestHandler( (webContents, permission, callback) => { if ( permission === "clipboard-read" || permission === "clipboard-sanitized-write" ) { callback(true); return; } callback(true); }, ); if (process.platform !== "darwin") { mainWindow.setMenuBarVisibility(false); } const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`; mainWindow.webContents.setUserAgent(customUserAgent); mainWindow.webContents.session.webRequest.onBeforeSendHeaders( (details, callback) => { details.requestHeaders["X-Electron-App"] = "true"; details.requestHeaders["User-Agent"] = customUserAgent; callback({ requestHeaders: details.requestHeaders }); }, ); if (isDev) { mainWindow.loadURL("http://localhost:5173"); mainWindow.webContents.openDevTools(); } else { const indexPath = path.join(appRoot, "dist", "index.html"); mainWindow.loadFile(indexPath).catch((err) => { console.error("Failed to load file:", err); }); } mainWindow.webContents.session.webRequest.onHeadersReceived( (details, callback) => { const headers = details.responseHeaders; if (headers) { delete headers["x-frame-options"]; delete headers["X-Frame-Options"]; if (headers["content-security-policy"]) { headers["content-security-policy"] = headers[ "content-security-policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["content-security-policy"].length === 0) { delete headers["content-security-policy"]; } } if (headers["Content-Security-Policy"]) { headers["Content-Security-Policy"] = headers[ "Content-Security-Policy" ] .map((value) => value.replace(/frame-ancestors[^;]*/gi, "")) .filter((value) => value.trim().length > 0); if (headers["Content-Security-Policy"].length === 0) { delete headers["Content-Security-Policy"]; } } if (headers["set-cookie"]) { headers["set-cookie"] = headers["set-cookie"].map((cookie) => { let modified = cookie.replace( /;\s*SameSite=Strict/gi, "; SameSite=None", ); modified = modified.replace( /;\s*SameSite=Lax/gi, "; SameSite=None", ); if (!modified.includes("SameSite=")) { modified += "; SameSite=None"; } if ( !modified.includes("Secure") && details.url.startsWith("https") ) { modified += "; Secure"; } return modified; }); } } callback({ responseHeaders: headers }); }, ); mainWindow.once("ready-to-show", () => { mainWindow.show(); }); setTimeout(() => { if (mainWindow && !mainWindow.isVisible()) { mainWindow.show(); } }, 3000); mainWindow.webContents.on( "did-fail-load", (event, errorCode, errorDescription, validatedURL) => { console.error( "Failed to load:", errorCode, errorDescription, validatedURL, ); }, ); mainWindow.webContents.on("did-finish-load", () => { console.log("Frontend loaded successfully"); }); mainWindow.on("close", (event) => { if (!isQuitting && tray && !tray.isDestroyed()) { event.preventDefault(); mainWindow.hide(); } }); mainWindow.on("closed", () => { mainWindow = null; }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: "deny" }; }); } ipcMain.handle("get-app-version", () => { return app.getVersion(); }); const GITHUB_API_BASE = "https://api.github.com"; const REPO_OWNER = "Termix-SSH"; const REPO_NAME = "Termix"; const githubCache = new Map(); const CACHE_DURATION = 30 * 60 * 1000; async function fetchGitHubAPI(endpoint, cacheKey) { const cached = githubCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { return { data: cached.data, cached: true, cache_age: Date.now() - cached.timestamp, }; } try { const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "TermixElectronUpdateChecker/1.0", "X-GitHub-Api-Version": "2022-11-28", }, timeout: 10000, }); if (!response.ok) { throw new Error( `GitHub API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); githubCache.set(cacheKey, { data, timestamp: Date.now(), }); return { data: data, cached: false, }; } catch (error) { console.error("Failed to fetch from GitHub API:", error); throw error; } } ipcMain.handle("check-electron-update", async () => { try { const localVersion = app.getVersion(); const releaseData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, "latest_release_electron", ); const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; if (!remoteVersion) { return { success: false, error: "Remote version not found", localVersion, }; } const isUpToDate = localVersion === remoteVersion; const result = { success: true, status: isUpToDate ? "up_to_date" : "requires_update", localVersion: localVersion, remoteVersion: remoteVersion, latest_release: { tag_name: releaseData.data.tag_name, name: releaseData.data.name, published_at: releaseData.data.published_at, html_url: releaseData.data.html_url, body: releaseData.data.body, }, cached: releaseData.cached, cache_age: releaseData.cache_age, }; return result; } catch (error) { return { success: false, error: error.message, localVersion: app.getVersion(), }; } }); ipcMain.handle("get-platform", () => { return process.platform; }); ipcMain.handle("get-embedded-server-status", () => { return { running: backendProcess !== null && !backendProcess.killed, embedded: !isDev, dataDir: isDev ? null : getBackendDataDir(), }; }); ipcMain.handle("get-server-config", () => { try { const userDataPath = app.getPath("userData"); const configPath = path.join(userDataPath, "server-config.json"); if (fs.existsSync(configPath)) { const configData = fs.readFileSync(configPath, "utf8"); return JSON.parse(configData); } return null; } catch (error) { console.error("Error reading server config:", error); return null; } }); ipcMain.handle("save-server-config", (event, config) => { try { const userDataPath = app.getPath("userData"); const configPath = path.join(userDataPath, "server-config.json"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); return { success: true }; } catch (error) { console.error("Error saving server config:", error); return { success: false, error: error.message }; } }); ipcMain.handle("get-setting", (event, key) => { try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); if (!fs.existsSync(settingsPath)) { return null; } const settingsData = fs.readFileSync(settingsPath, "utf8"); const settings = JSON.parse(settingsData); return settings[key] !== undefined ? settings[key] : null; } catch (error) { console.error("Error reading setting:", error); return null; } }); ipcMain.handle("set-setting", (event, key, value) => { try { const userDataPath = app.getPath("userData"); const settingsPath = path.join(userDataPath, "settings.json"); if (!fs.existsSync(userDataPath)) { fs.mkdirSync(userDataPath, { recursive: true }); } let settings = {}; if (fs.existsSync(settingsPath)) { const settingsData = fs.readFileSync(settingsPath, "utf8"); settings = JSON.parse(settingsData); } settings[key] = value; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); return { success: true }; } catch (error) { console.error("Error saving setting:", error); return { success: false, error: error.message }; } }); ipcMain.handle("clear-session-cookies", async () => { try { const ses = mainWindow?.webContents?.session; if (ses) { const cookies = await ses.cookies.get({}); for (const cookie of cookies) { const scheme = cookie.secure ? "https" : "http"; const domain = cookie.domain?.startsWith(".") ? cookie.domain.slice(1) : cookie.domain || "localhost"; const url = `${scheme}://${domain}${cookie.path || "/"}`; await ses.cookies.remove(url, cookie.name); } } } catch (error) { console.error("Failed to clear session cookies:", error); } }); ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const normalizedServerUrl = serverUrl.replace(/\/$/, ""); const healthUrl = `${normalizedServerUrl}/health`; try { const response = await httpFetch(healthUrl, { method: "GET", timeout: 10000, }); if (response.ok) { const data = await response.text(); if ( data.includes("") || data.includes("") ) { return { success: false, error: "Server returned HTML instead of JSON. This does not appear to be a Termix server.", }; } try { const healthData = JSON.parse(data); if ( healthData && (healthData.status === "ok" || healthData.status === "healthy" || healthData.healthy === true || healthData.database === "connected") ) { return { success: true, status: response.status, testedUrl: healthUrl, }; } } catch (parseError) { console.log("Health endpoint did not return valid JSON"); } } } catch (urlError) { console.error("Health check failed:", urlError); } try { const versionUrl = `${normalizedServerUrl}/version`; const response = await httpFetch(versionUrl, { method: "GET", timeout: 10000, }); if (response.ok) { const data = await response.text(); if ( data.includes("") || data.includes("") ) { return { success: false, error: "Server returned HTML instead of JSON. This does not appear to be a Termix server.", }; } try { const versionData = JSON.parse(data); if ( versionData && (versionData.status === "up_to_date" || versionData.status === "requires_update" || (versionData.localVersion && versionData.version && versionData.latest_release)) ) { return { success: true, status: response.status, testedUrl: versionUrl, warning: "Health endpoint not available, but server appears to be running", }; } } catch (parseError) { console.log("Version endpoint did not return valid JSON"); } } } catch (versionError) { console.error("Version check failed:", versionError); } return { success: false, error: "Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.", }; } catch (error) { return { success: false, error: error.message }; } }); function createMenu() { if (process.platform === "darwin") { const template = [ { label: app.name, submenu: [ { role: "about" }, { type: "separator" }, { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }, ], }, { label: "Edit", submenu: [ { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }, ], }, { label: "View", submenu: [ { role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, ], }, { label: "Window", submenu: [ { role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }, { type: "separator" }, { role: "window" }, ], }, ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } } app.whenReady().then(async () => { logToFile("=== App ready ==="); logToFile( "isDev:", isDev, "platform:", process.platform, "arch:", process.arch, ); createMenu(); if (!isDev) { const result = await startBackendServer(); logToFile("startBackendServer result:", result); } else { logToFile( "Skipping embedded backend (isDev=true) - expecting separate dev:backend process", ); } createTray(); createWindow(); logToFile("=== Startup complete ==="); }); app.on("window-all-closed", () => { if (!tray || tray.isDestroyed()) { app.quit(); } }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); app.on("before-quit", () => { isQuitting = true; }); app.on("will-quit", () => { console.log("App will quit..."); stopBackendServer(); }); process.on("uncaughtException", (error) => { console.error("Uncaught Exception:", error); }); process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled Rejection at:", promise, "reason:", reason); }); ================================================ FILE: electron/preload.js ================================================ const { contextBridge, ipcRenderer } = require("electron"); const { clipboard } = require("electron"); contextBridge.exposeInMainWorld("electronAPI", { getAppVersion: () => ipcRenderer.invoke("get-app-version"), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), isElectron: true, isDev: process.env.NODE_ENV === "development", getSetting: (key) => ipcRenderer.invoke("get-setting", key), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); contextBridge.exposeInMainWorld("electronClipboard", { writeText: (text) => clipboard.writeText(text), readText: () => clipboard.readText(), }); window.IS_ELECTRON = true; ================================================ FILE: electron-builder.json ================================================ { "appId": "com.karmaa.termix", "productName": "Termix", "publish": null, "directories": { "output": "release" }, "asar": false, "files": [ "dist/**/*", "electron/**/*", "public/**/*", "!src/**/*", "!*.md", "!tsconfig*.json", "!vite.config.ts", "!eslint.config.js", "!node_modules/@napi-rs/canvas*/**/*", "!node_modules/@rollup/rollup-darwin-*/**/*", "!node_modules/@rollup/rollup-linux-*/**/*", "!node_modules/@rollup/rollup-win32-*/**/*", "!dist/icon-mac.png", "!public/icon-mac.png", "!dist/icon.ico", "!public/icon.ico", "!dist/icon.icns", "!public/icon.icns", "!dist/icons/**/*", "!public/icons/**/*" ], "extraMetadata": { "main": "electron/main.cjs" }, "buildDependenciesFromSource": false, "nodeGypRebuild": false, "npmRebuild": true, "win": { "target": [ { "target": "nsis", "arch": ["x64", "ia32"] }, { "target": "msi", "arch": ["x64", "ia32"] } ], "icon": "public/icon.ico", "executableName": "Termix" }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "artifactName": "termix_windows_${arch}_nsis.${ext}", "createDesktopShortcut": true, "createStartMenuShortcut": true, "shortcutName": "Termix", "uninstallDisplayName": "Termix" }, "msi": { "artifactName": "termix_windows_${arch}_msi.${ext}" }, "linux": { "artifactName": "termix_linux_${arch}_portable.${ext}", "target": [ { "target": "AppImage", "arch": ["x64", "arm64", "armv7l"] }, { "target": "deb", "arch": ["x64", "arm64", "armv7l"] }, { "target": "tar.gz", "arch": ["x64", "arm64", "armv7l"] } ], "icon": "public/icon.png", "category": "Development", "executableName": "termix", "maintainer": "Termix ", "desktop": { "entry": { "Name": "Termix", "Comment": "A web-based server management platform", "Keywords": "terminal;ssh;server;management;", "StartupWMClass": "termix" } } }, "appImage": { "artifactName": "termix_linux_${arch}_appimage.${ext}" }, "deb": { "artifactName": "termix_linux_${arch}_deb.${ext}" }, "mac": { "target": [ { "target": "mas", "arch": "universal" }, { "target": "dmg", "arch": ["universal", "x64", "arm64"] } ], "icon": "public/icon.icns", "category": "public.app-category.developer-tools", "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.inherit.plist", "type": "distribution", "minimumSystemVersion": "10.15", "mergeASARs": false, "singleArchFiles": "**/*.node", "x64ArchFiles": "**/*.node" }, "dmg": { "artifactName": "termix_macos_${arch}_dmg.${ext}", "sign": true }, "afterSign": "build/notarize.cjs", "mas": { "provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile", "entitlements": "build/entitlements.mas.plist", "entitlementsInherit": "build/entitlements.mas.inherit.plist", "hardenedRuntime": false, "gatekeeperAssess": false, "asar": false, "type": "distribution", "category": "public.app-category.developer-tools", "artifactName": "termix_macos_${arch}_mas.${ext}", "extendInfo": { "ITSAppUsesNonExemptEncryption": false, "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations." } }, "generateUpdatesFilesForAllChannels": true } ================================================ FILE: eslint.config.js ================================================ import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; import { globalIgnores } from "eslint/config"; export default tseslint.config([ globalIgnores(["dist", "release", "Mobile"]), { files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, reactHooks.configs["recommended-latest"], reactRefresh.configs.vite, ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, rules: { "@typescript-eslint/no-unused-vars": "warn", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-expressions": "warn", "no-empty": "warn", "no-control-regex": "off", "react-refresh/only-export-components": "warn", }, }, ]); ================================================ FILE: flatpak/com.karmaa.termix.desktop ================================================ [Desktop Entry] Name=Termix Comment=Web-based server management platform with SSH terminal, tunneling, and file editing Exec=run.sh %U Icon=com.karmaa.termix Terminal=false Type=Application Categories=Development;Network;System; Keywords=ssh;terminal;server;management;tunnel; StartupWMClass=termix StartupNotify=true ================================================ FILE: flatpak/com.karmaa.termix.flatpakref ================================================ [Flatpak Ref] Name=Termix Branch=stable Title=Termix - SSH Server Management Platform IsRuntime=false Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo Comment=Web-based server management platform with SSH terminal, tunneling, and file editing Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management. Icon=https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png Homepage=https://github.com/Termix-SSH/Termix ================================================ FILE: flatpak/com.karmaa.termix.metainfo.xml ================================================ com.karmaa.termix Termix Web-based server management platform with SSH terminal, tunneling, and file editing CC0-1.0 Apache-2.0 bugattiguy527

Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.

Features:

  • SSH terminal access with full terminal emulation
  • SSH tunneling capabilities for secure port forwarding
  • Remote file management with editor support
  • Server monitoring and management tools
  • Self-hosted solution - keep your data private
  • Modern, intuitive web interface
com.karmaa.termix.desktop https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/screenshots/terminal.png SSH Terminal Interface https://github.com/Termix-SSH/Termix https://github.com/Termix-SSH/Support/issues https://docs.termix.site https://github.com/Termix-SSH/Termix moderate

Latest release of Termix

https://github.com/Termix-SSH/Termix/releases
Development Network System ssh terminal server management tunnel file-manager termix always
================================================ FILE: flatpak/com.karmaa.termix.yml ================================================ app-id: com.karmaa.termix runtime: org.freedesktop.Platform runtime-version: "24.08" sdk: org.freedesktop.Sdk base: org.electronjs.Electron2.BaseApp base-version: "24.08" command: run.sh separate-locales: false finish-args: - --socket=x11 - --socket=wayland - --socket=pulseaudio - --share=network - --share=ipc - --device=dri - --filesystem=home - --socket=ssh-auth - --socket=session-bus - --talk-name=org.freedesktop.secrets - --env=ELECTRON_TRASH=gio - --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons - --env=ELECTRON_OZONE_PLATFORM_HINT=auto modules: - name: termix buildsystem: simple build-commands: - chmod +x termix.AppImage - ./termix.AppImage --appimage-extract - install -Dm755 squashfs-root/termix /app/bin/termix - cp -r squashfs-root/resources /app/bin/ - cp -r squashfs-root/locales /app/bin/ || true - cp squashfs-root/*.so /app/bin/ || true - cp squashfs-root/*.pak /app/bin/ || true - cp squashfs-root/*.bin /app/bin/ || true - cp squashfs-root/*.dat /app/bin/ || true - cp squashfs-root/*.json /app/bin/ || true - | cat > run.sh << 'EOF' #!/bin/bash export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID" exec zypak-wrapper /app/bin/termix "$@" EOF - chmod +x run.sh - install -Dm755 run.sh /app/bin/run.sh - install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop - install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml - install -Dm644 com.karmaa.termix.svg /app/share/icons/hicolor/scalable/apps/com.karmaa.termix.svg - install -Dm644 icon-256.png /app/share/icons/hicolor/256x256/apps/com.karmaa.termix.png || true - install -Dm644 icon-128.png /app/share/icons/hicolor/128x128/apps/com.karmaa.termix.png || true sources: - type: file url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage sha256: CHECKSUM_X64_PLACEHOLDER dest-filename: termix.AppImage only-arches: - x86_64 - type: file url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage sha256: CHECKSUM_ARM64_PLACEHOLDER dest-filename: termix.AppImage only-arches: - aarch64 - type: file path: com.karmaa.termix.desktop - type: file path: com.karmaa.termix.metainfo.xml - type: file path: com.karmaa.termix.svg - type: file path: icon-256.png - type: file path: icon-128.png ================================================ FILE: flatpak/flathub.json ================================================ { "only-arches": ["x86_64", "aarch64"], "skip-icons-check": false, "skip-appstream-check": false } ================================================ FILE: index.html ================================================ Termix
================================================ FILE: package.json ================================================ { "name": "termix", "private": true, "version": "2.0.0", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", "type": "module", "scripts": { "clean": "npx prettier . --write", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", "lint:fix": "eslint --fix .", "type-check": "tsc --noEmit", "dev": "vite", "build": "vite build && tsc -p tsconfig.node.json", "build:backend": "tsc -p tsconfig.node.json", "dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev", "generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js", "preview": "vite preview", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:rebuild": "electron-rebuild -f -w better-sqlite3", "build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir", "build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never", "build:linux-portable": "npm run build && npm run electron:rebuild && electron-builder --linux --dir", "build:linux-appimage": "npm run build && npm run electron:rebuild && electron-builder --linux AppImage", "build:linux-targz": "npm run build && npm run electron:rebuild && electron-builder --linux tar.gz", "build:mac": "npm run build && npm run electron:rebuild && electron-builder --mac --universal" }, "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.3.3", "@codemirror/search": "^6.5.11", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.23.1", "@hookform/resolvers": "^5.1.1", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.14", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.9", "@types/cytoscape": "^3.21.9", "@types/guacamole-common-js": "^1.5.5", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", "@types/react-grid-layout": "^1.3.6", "@types/speakeasy": "^2.0.10", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-theme-github": "^4.25.4", "@uiw/react-codemirror": "^4.24.1", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "axios": "^1.10.0", "bcryptjs": "^3.0.2", "better-sqlite3": "^12.2.0", "body-parser": "^1.20.2", "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cytoscape": "^3.33.1", "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", "express": "^5.1.0", "guacamole-common-js": "^1.5.0", "guacamole-lite": "^1.2.0", "https-proxy-agent": "^7.0.6", "i18n-auto-translation": "^2.2.3", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", "lucide-react": "^0.525.0", "multer": "^2.0.2", "nanoid": "^5.1.5", "next-themes": "^0.4.6", "node-fetch": "^3.3.2", "qrcode": "^1.5.4", "react": "^19.1.0", "react-cytoscapejs": "^2.0.0", "react-dom": "^19.1.0", "react-grid-layout": "^2.2.2", "react-h5-audio-player": "^3.10.1", "react-hook-form": "^7.60.0", "react-i18next": "^15.7.3", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-pdf": "^10.1.0", "react-photo-view": "^1.2.7", "react-player": "^3.3.3", "react-resizable-panels": "^3.0.3", "react-simple-keyboard": "^3.8.120", "react-syntax-highlighter": "^15.6.6", "react-xtermjs": "^1.0.10", "recharts": "^3.2.1", "remark-gfm": "^4.0.1", "socks": "^2.8.7", "sonner": "^2.0.7", "speakeasy": "^2.0.0", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.14", "wait-on": "^9.0.1", "ws": "^8.18.3", "zod": "^4.0.5" }, "devDependencies": { "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@electron/notarize": "^2.5.0", "@electron/rebuild": "^3.7.2", "@eslint/js": "^9.34.0", "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.3.0", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/ssh2": "^1.15.5", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.2.1", "electron": "^38.0.0", "electron-builder": "^26.0.12", "eslint": "^9.34.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", "husky": "^9.1.7", "lint-staged": "^16.2.3", "prettier": "3.6.2", "swagger-jsdoc": "^6.2.8", "typescript": "~5.9.2", "typescript-eslint": "^8.40.0", "vite": "^7.1.5" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ "prettier --write" ], "*.{json,css,md}": [ "prettier --write" ] } } ================================================ FILE: public/manifest.json ================================================ { "name": "Termix", "short_name": "Termix", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "theme_color": "#09090b", "background_color": "#09090b", "display": "standalone", "orientation": "any", "scope": "./", "start_url": "./", "icons": [ { "src": "./icons/48x48.png", "sizes": "48x48", "type": "image/png" }, { "src": "./icons/64x64.png", "sizes": "64x64", "type": "image/png" }, { "src": "./icons/128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "./icons/256x256.png", "sizes": "256x256", "type": "image/png" }, { "src": "./icons/512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ], "categories": ["utilities", "developer", "productivity"] } ================================================ FILE: public/sw.js ================================================ const CACHE_NAME = "termix-v1"; const STATIC_ASSETS = [ "/", "/index.html", "/manifest.json", "/favicon.ico", "/icons/48x48.png", "/icons/128x128.png", "/icons/256x256.png", "/icons/512x512.png", ]; self.addEventListener("install", (event) => { event.waitUntil( caches .open(CACHE_NAME) .then((cache) => { return cache.addAll(STATIC_ASSETS); }) .then(() => { return self.skipWaiting(); }), ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME) .map((name) => { return caches.delete(name); }), ); }) .then(() => { return self.clients.claim(); }), ); }); self.addEventListener("fetch", (event) => { const { request } = event; const url = new URL(request.url); if (request.method !== "GET") { return; } if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/ws")) { return; } if ( url.pathname.startsWith("/ssh/opkssh-chooser/") || url.pathname.startsWith("/ssh/opkssh-callback/") ) { return; } if (url.origin !== self.location.origin) { return; } if (request.mode === "navigate") { event.respondWith( fetch(request).catch(() => { return caches.match("/index.html"); }), ); return; } const isStaticAsset = STATIC_ASSETS.some((asset) => { if (asset === "/") return url.pathname === "/"; return url.pathname === asset || url.pathname.startsWith("/assets/"); }); if (!isStaticAsset) { return; } event.respondWith( caches.match(request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(request).then((response) => { if (!response || response.status !== 200 || response.type !== "basic") { return response; } const responseClone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(request, responseClone); }); return response; }); }), ); }); ================================================ FILE: readme/README-AR.md ================================================ # إحصائيات المستودع

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
تم تحقيقه في 1 سبتمبر 2025


Termix Banner

إذا كنت ترغب في ذلك، يمكنك دعم المشروع هنا!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # نظرة عامة

Termix Banner

Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، وقدرات إنشاء أنفاق SSH، وإدارة الملفات عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات. # الميزات - **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى - **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة - **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r - **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo - **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها - **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH - **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux - **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم - **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار - **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً - **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات - **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات - **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS - **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين الوضع الداكن أو الفاتح. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة - **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)) - **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android - **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة - **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً - **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال - **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح - **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها - **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة - **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم # الميزات المخططة راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # التثبيت الأجهزة المدعومة: - الموقع الإلكتروني (أي متصفح حديث على أي منصة مثل Chrome و Safari و Firefox) (يتضمن دعم PWA) - Windows (x64/ia32) - نسخة محمولة - مثبت MSI - مدير حزم Chocolatey - Linux (x64/ia32) - نسخة محمولة - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 على الإصدار 12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (الإصدار 15.1+) - Apple App Store - IPA - Android (الإصدار 7.0+) - Google Play Store - APK قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # الرعاة

DigitalOcean          Crowdin          Crowdin          Crowdin

# الدعم إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`. يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول. # لقطات الشاشة [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي. # الترخيص موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف LICENSE لمزيد من المعلومات. ================================================ FILE: readme/README-CN.md ================================================ # 仓库统计

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
2025年9月1日获得


Termix Banner

如果你愿意,可以在这里支持这个项目!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # 概览

Termix Banner

Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix 提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。 # 功能 - **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件 - **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能 - **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能,支持 -l 或 -r 连接 - **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件,支持 sudo - **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们 - **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥 - **服务器统计** - 在大多数 Linux 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间、系统信息、防火墙、端口监控 - **仪表板** - 在仪表板上一目了然地查看服务器信息 - **RBAC** - 创建角色并在用户/角色之间共享主机 - **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起 - **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息 - **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 - **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 - **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。使用 URL 路由以全屏方式打开任何连接 - **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理) - **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序 - **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令 - **命令历史** - 自动完成并查看以前运行的 SSH 命令 - **快速连接** - 无需保存连接数据即可连接到服务器 - **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接 - **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)等 - **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示 - **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态 # 计划功能 查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。 # 安装 支持的设备: - 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持) - Windows(x64/ia32) - 便携版 - MSI 安装程序 - Chocolatey 软件包管理器 - Linux(x64/ia32) - 便携版 - AUR - AppImage - Deb - Flatpak - macOS(x64/ia32 on v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS(v15.1+) - Apple App Store - IPA - Android(v7.0+) - Google Play 商店 - APK 访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件(如果不打算使用远程桌面功能,可以省略 guacd 和 network): ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # 赞助商

DigitalOcean          Crowdin          Blacksmith          Cloudflare

# 支持 如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。 请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持 频道,但响应时间可能较长。 # 展示 [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

某些视频和图像可能已过时或可能无法完美展示功能。 # 许可证 根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。 ================================================ FILE: readme/README-DE.md ================================================ # Repo-Statistiken

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Erreicht am 1. September 2025


Termix Banner

Wenn Sie möchten, können Sie das Projekt hier unterstützen!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Überblick

Termix Banner

Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, SSH-Tunneling-Funktionen, Remote-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen. # Funktionen - **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten - **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen - **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen - **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung. - **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über 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. - **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren - **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen - **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen - **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen - **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen. - **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security). - **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren - **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen - **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen dunklem oder hellem Modus. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen. - **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations)) - **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS), PWA und dedizierte Mobil-/Tablet-App für iOS und Android. - **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus. - **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle - **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen - **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen - **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw. - **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren - **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert # Geplante Funktionen Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) für alle geplanten Funktionen. Wenn Sie beitragen möchten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Installation Unterstützte Geräte: - Website (jeder moderne Browser auf jeder Plattform wie Chrome, Safari und Firefox) (einschließlich PWA-Unterstützung) - Windows (x64/ia32) - Portabel - MSI-Installationsprogramm - Chocolatey-Paketmanager - Linux (x64/ia32) - Portabel - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 ab v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Sponsoren

DigitalOcean          Crowdin          Crowdin          Crowdin

# Support Wenn Sie Hilfe benötigen oder eine Funktion für Termix anfragen möchten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`. Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf Englisch. Sie können auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings können die Antwortzeiten dort länger sein. # Screenshots [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Einige Videos und Bilder können veraltet sein oder Funktionen möglicherweise nicht perfekt darstellen. # Lizenz Verteilt unter der Apache License Version 2.0. Siehe LICENSE für weitere Informationen. ================================================ FILE: readme/README-ES.md ================================================ # Estadísticas del Repositorio

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Logrado el 1 de septiembre de 2025


Termix Banner

Si lo desea, puede apoyar el proyecto aquí.\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Descripción General

Termix Banner

Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, capacidades de túneles SSH, gestión remota de archivos y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas. # Características - **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes - **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida - **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r - **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. - **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas 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. - **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH - **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux - **Dashboard** - Vea la información del servidor de un vistazo en su dashboard - **RBAC** - Cree roles y comparta hosts entre usuarios/roles - **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí. - **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información. - **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos - **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS - **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre modo oscuro o claro. Use rutas URL para abrir cualquier conexión en pantalla completa. - **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)) - **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS), PWA y aplicación dedicada para móviles/tablets en iOS y Android. - **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos. - **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente - **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión - **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado - **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc. - **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado - **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario # Características Planeadas Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las características planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Instalación Dispositivos soportados: - Sitio web (cualquier navegador moderno en cualquier plataforma como Chrome, Safari y Firefox) (incluye soporte PWA) - Windows (x64/ia32) - Portable - Instalador MSI - Gestor de paquetes Chocolatey - Linux (x64/ia32) - Portable - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 en v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Patrocinadores

DigitalOcean          Crowdin          Crowdin          Crowdin

# Soporte Si necesita ayuda o desea solicitar una función para Termix, visite la página de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesión y pulse `New Issue`. Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito en inglés. También puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser más largos. # Capturas de Pantalla [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Algunos videos e imágenes pueden estar desactualizados o no mostrar perfectamente las características. # Licencia Distribuido bajo la Licencia Apache Versión 2.0. Consulte LICENSE para más información. ================================================ FILE: readme/README-FR.md ================================================ # Statistiques du dépôt

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Obtenu le 1er septembre 2025


Termix Banner

Si vous le souhaitez, vous pouvez soutenir le projet ici !\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Présentation

Termix Banner

Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, des capacités de tunneling SSH, la gestion de fichiers à distance, et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes. # Fonctionnalités - **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants - **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé - **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r - **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo - **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer - **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH - **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux - **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord - **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles - **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble - **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails - **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers - **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS - **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez entre un thème sombre ou clair. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran - **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations)) - **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS), PWA, et application mobile/tablette dédiée pour iOS et Android - **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts - **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées - **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion - **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier - **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc. - **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts - **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur # Fonctionnalités prévues Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalités prévues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Installation Appareils supportés : - Site web (tout navigateur moderne sur toute plateforme comme Chrome, Safari et Firefox) (support PWA inclus) - Windows (x64/ia32) - Portable - Installateur MSI - Gestionnaire de paquets Chocolatey - Linux (x64/ia32) - Portable - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 sur v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose : ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Sponsors

DigitalOcean          Crowdin          Crowdin          Crowdin

# Support Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez être aussi détaillé que possible dans votre issue, de préférence rédigée en anglais. Vous pouvez également rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de réponse peuvent être plus longs. # Captures d'écran [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Certaines vidéos et images peuvent être obsolètes ou ne pas présenter parfaitement les fonctionnalités. # Licence Distribué sous la licence Apache Version 2.0. Consultez LICENSE pour plus d'informations. ================================================ FILE: readme/README-HI.md ================================================ # रिपॉजिटरी आँकड़े

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
1 सितंबर, 2025 को प्राप्त


Termix Banner

यदि आप चाहें, तो आप यहाँ प्रोजेक्ट को सपोर्ट कर सकते हैं!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # अवलोकन

Termix Banner

Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, SSH टनलिंग क्षमताएँ, रिमोट फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है। # विशेषताएँ - **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है - **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ - **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ - **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें - **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है - **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें - **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें - **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें - **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें - **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें - **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें - **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें - **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन - **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। डार्क या लाइट मोड UI के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें - **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित) - **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध - **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ - **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य - **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें - **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें - **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट - **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें - **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं # नियोजित विशेषताएँ सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें। # इंस्टॉलेशन समर्थित डिवाइस: - वेबसाइट (किसी भी प्लेटफ़ॉर्म पर कोई भी आधुनिक ब्राउज़र जैसे Chrome, Safari, और Firefox) (PWA सपोर्ट सहित) - Windows (x64/ia32) - पोर्टेबल - MSI इंस्टॉलर - Chocolatey पैकेज मैनेजर - Linux (x64/ia32) - पोर्टेबल - AUR - AppImage - Deb - Flatpak - macOS (v12.0+ पर x64/ia32) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # प्रायोजक

DigitalOcean          Crowdin          Crowdin          Crowdin

# सहायता यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ। कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है। # स्क्रीनशॉट [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं। # लाइसेंस Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए LICENSE देखें। ================================================ FILE: readme/README-IT.md ================================================ # Statistiche Repo

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Ottenuto il 1 settembre 2025


Termix Banner

Se lo desideri, puoi supportare il progetto qui!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Panoramica

Termix Banner

Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, funzionalità di tunneling SSH, gestione remota dei file e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme. # Funzionalità - **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni - **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso - **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r - **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. - **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. - **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH - **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux - **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard - **RBAC** - Crea ruoli e condividi host tra utenti/ruoli - **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. - **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni. - **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file - **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS - **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra modalità scura o chiara. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero. - **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)) - **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS), PWA e app dedicata per mobile/tablet su iOS e Android. - **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti. - **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza - **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione - **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera - **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc. - **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato - **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente # Funzionalità Pianificate Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Installazione Dispositivi Supportati: - Sito web (qualsiasi browser moderno su qualsiasi piattaforma come Chrome, Safari e Firefox) (include supporto PWA) - Windows (x64/ia32) - Portable - MSI Installer - Chocolatey Package Manager - Linux (x64/ia32) - Portable - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 su v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Sponsor

DigitalOcean          Crowdin          Crowdin          Crowdin

# Supporto Se hai bIPAgno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`. Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi. # Screenshot [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità. # Licenza Distribuito sotto la Licenza Apache Versione 2.0. Consulta LICENSE per maggiori informazioni. ================================================ FILE: readme/README-JA.md ================================================ # リポジトリ統計

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
2025年9月1日に達成


Termix Banner

プロジェクトを支援していただける方はこちらからどうぞ!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # 概要

Termix Banner

Termixは、オープンソースで永久無料のセルフホスト型オールインワンサーバー管理プラットフォームです。単一の直感的なインターフェースを通じて、サーバーとインフラストラクチャを管理するマルチプラットフォームソリューションを提供します。Termixは、SSHターミナルアクセス、SSHトンネリング機能、リモートファイル管理、その他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。 # 機能 - **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応 - **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応 - **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応 - **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行 - **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています - **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化 - **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示 - **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認 - **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有 - **ユーザー認証** - 管理者コントロールとOIDCおよび2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携 - **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください - **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポート - **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理 - **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ダーク/ライトモードの切り替え対応。URLルートで任意の接続をフルスクリーンで開くことが可能 - **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理) - **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能 - **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行 - **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示 - **クイック接続** - 接続データを保存せずにサーバーに接続 - **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス - **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応 - **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化 - **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます # 予定されている機能 すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。 # インストール 対応デバイス: - Webサイト(Chrome、Safari、Firefoxなど、あらゆるプラットフォームのモダンブラウザ)(PWA対応) - Windows (x64/ia32) - ポータブル版 - MSIインストーラー - Chocolateyパッケージマネージャー - Linux (x64/ia32) - ポータブル版 - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32、v12.0以降) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1以降) - Apple App Store - IPA - Android (v7.0以降) - Google Play Store - APK すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。以下はDocker Composeファイルのサンプルです: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # スポンサー

DigitalOcean          Crowdin          Crowdin          Crowdin

# サポート Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。 # スクリーンショット [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

一部の動画や画像は古い場合や、機能を完全に紹介していない場合があります。 # ライセンス Apache License Version 2.0のもとで配布されています。詳細はLICENSEをご覧ください。 ================================================ FILE: readme/README-KO.md ================================================ # 리포지토리 통계

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
2025년 9월 1일 달성


Termix Banner

프로젝트를 후원하고 싶으시다면 여기에서 지원해 주세요!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # 개요

Termix Banner

Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, SSH 터널링 기능, 원격 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다. # 기능 - **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원 - **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원 - **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원 - **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행 - **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다 - **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화 - **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시 - **대시보드** - 대시보드에서 서버 정보를 한눈에 확인 - **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유 - **사용자 인증** - 관리자 제어와 OIDC 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동 - **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요 - **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기 - **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리 - **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 다크 또는 라이트 모드 기반 UI 선택. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능 - **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리) - **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공 - **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행 - **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회 - **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속 - **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근 - **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원 - **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화 - **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지 # 계획된 기능 모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요. # 설치 지원 기기: - 웹사이트 (Chrome, Safari, Firefox 등 모든 플랫폼의 최신 브라우저) (PWA 지원 포함) - Windows (x64/ia32) - 포터블 - MSI 설치 프로그램 - Chocolatey 패키지 관리자 - Linux (x64/ia32) - 포터블 - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32, v12.0 이상) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1 이상) - Apple App Store - IPA - Android (v7.0 이상) - Google Play Store - APK 모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # 스폰서

DigitalOcean          Crowdin          Crowdin          Crowdin

# 지원 Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다. # 스크린샷 [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다. # 라이선스 Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 LICENSE를 참조하세요. ================================================ FILE: readme/README-PT.md ================================================ # Estatísticas do Repositório

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Conquistado em 1 de setembro de 2025


Termix Banner

Se desejar, você pode apoiar o projeto aqui!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Visão Geral

Termix Banner

Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, capacidades de tunelamento SSH, gerenciamento remoto de arquivos e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas. # Funcionalidades - **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes - **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida - **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r - **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. - **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los. - **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH - **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux - **Dashboard** - Visualize informações do servidor de relance no seu dashboard - **RBAC** - Crie funções e compartilhe hosts entre usuários/funções - **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si. - **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações. - **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos - **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS - **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre modo escuro ou claro. Use rotas de URL para abrir qualquer conexão em tela cheia. - **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)) - **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS), PWA e aplicativo dedicado para celular/tablet para iOS e Android. - **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos. - **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente - **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão - **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado - **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc. - **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status - **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário # Funcionalidades Planejadas Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se você deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Instalação Dispositivos suportados: - Website (qualquer navegador moderno em qualquer plataforma como Chrome, Safari e Firefox) (inclui suporte PWA) - Windows (x64/ia32) - Portátil - Instalador MSI - Gerenciador de pacotes Chocolatey - Linux (x64/ia32) - Portátil - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 em v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Patrocinadores

DigitalOcean          Crowdin          Crowdin          Crowdin

# Suporte Se você precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a página de [Issues](https://github.com/Termix-SSH/Support/issues), faça login e clique em `New Issue`. Por favor, seja o mais detalhado possível no seu relato, preferencialmente escrito em inglês. Você também pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porém, os tempos de resposta podem ser mais longos. # Capturas de Tela [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades. # Licença Distribuído sob a Licença Apache Versão 2.0. Consulte LICENSE para mais informações. ================================================ FILE: readme/README-RU.md ================================================ # Статистика репозитория

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Достигнуто 1 сентября 2025 года


Termix Banner

Если хотите, вы можете поддержать проект здесь!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Обзор

Termix Banner

Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, возможности SSH-туннелирования, удалённое управление файлами и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ. # Возможности - **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты - **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана - **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r - **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo - **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием - **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей - **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux - **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом - **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей - **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов - **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security) - **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера - **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS - **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между тёмной и светлой темой. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме - **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations)) - **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS), PWA и специализированное мобильное/планшетное приложение для iOS и Android - **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах - **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд - **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения - **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры - **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др. - **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов - **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя # Запланированные функции Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Установка Поддерживаемые устройства: - Веб-сайт (любой современный браузер на любой платформе, включая Chrome, Safari и Firefox) (включая поддержку PWA) - Windows (x64/ia32) - Портативная версия - Установщик MSI - Менеджер пакетов Chocolatey - Linux (x64/ia32) - Портативная версия - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32, версия 12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (версия 15.1+) - Apple App Store - IPA - Android (версия 7.0+) - Google Play Store - APK Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Спонсоры

DigitalOcean          Crowdin          Crowdin          Crowdin

# Поддержка Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`. Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше. # Скриншоты [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. # Лицензия Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле LICENSE. ================================================ FILE: readme/README-TR.md ================================================ # Repo İstatistikleri

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
1 Eylül 2025'te kazanıldı


Termix Banner

Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Genel Bakış

Termix Banner

Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, SSH tünelleme yetenekleri, uzak dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir. # Özellikler - **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir - **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği - **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle - **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın. - **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır. - **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin - **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin - **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin - **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın - **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın. - **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın. - **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın - **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi - **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Karanlık veya açık tema arasında seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın. - **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir) - **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir. - **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın. - **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin - **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın - **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin - **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler. - **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin - **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır # Planlanan Özellikler Tüm planlanan özellikler için [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasına bakın. Katkıda bulunmak istiyorsanız, [Katkıda Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasına bakın. # Kurulum Desteklenen Cihazlar: - Web sitesi (Chrome, Safari ve Firefox gibi herhangi bir platformda herhangi bir modern tarayıcı) (PWA desteği dahil) - Windows (x64/ia32) - Taşınabilir - MSI Yükleyici - Chocolatey Paket Yöneticisi - Linux (x64/ia32) - Taşınabilir - AUR - AppImage - Deb - Flatpak - macOS (v12.0+ üzerinde x64/ia32) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Sponsorlar

DigitalOcean          Crowdin          Crowdin          Crowdin

# Destek Termix ile ilgili yardıma ihtiyacınız varsa veya bir özellik talep etmek istiyorsanız, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasını ziyaret edin, giriş yapın ve `New Issue` butonuna basın. Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce olarak. Ayrıca [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katılabilir ve destek kanalını ziyaret edebilirsiniz, ancak yanıt süreleri daha uzun olabilir. # Ekran Görüntüleri [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir. # Lisans Apache Lisansı Sürüm 2.0 altında dağıtılmaktadır. Daha fazla bilgi için LICENSE dosyasına bakın. ================================================ FILE: readme/README-VI.md ================================================ # Thống Kê Repo

English English · 中文 中文 · 日本語 日本語 · 한국어 한국어 · Français Français · Deutsch Deutsch · Español Español · Português Português · Русский Русский · العربية العربية · हिन्दी हिन्दी · Türkçe Türkçe · Tiếng Việt Tiếng Việt · Italiano Italiano

![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub forks](https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks) ![GitHub Release](https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release) Discord

Repo of the Day Achievement
Đạt được vào ngày 1 tháng 9 năm 2025


Termix Banner

Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\ [![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus) # Tổng Quan

Termix Banner

Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, khả năng tạo đường hầm SSH, quản lý tệp từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng. # Tính Năng - **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác - **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình - **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r - **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo. - **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng. - **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH - **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux - **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn - **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò - **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau. - **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm. - **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp - **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS - **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa giao diện chế độ tối hoặc sáng. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình. - **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations)) - **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android. - **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở. - **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó - **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối - **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím - **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v. - **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái - **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng # Tính Năng Dự Kiến Xem [Dự Án](https://github.com/orgs/Termix-SSH/projects/2) để biết tất cả các tính năng dự kiến. Nếu bạn muốn đóng góp, xem [Đóng Góp](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md). # Cài Đặt Thiết Bị Được Hỗ Trợ: - Trang web (bất kỳ trình duyệt hiện đại nào trên bất kỳ nền tảng nào như Chrome, Safari và Firefox) (bao gồm hỗ trợ PWA) - Windows (x64/ia32) - Portable - MSI Installer - Chocolatey Package Manager - Linux (x64/ia32) - Portable - AUR - AppImage - Deb - Flatpak - macOS (x64/ia32 trên v12.0+) - Apple App Store - DMG - Homebrew - iOS/iPadOS (v15.1+) - Apple App Store - IPA - Android (v7.0+) - Google Play Store - APK Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây: ```yaml services: termix: image: ghcr.io/lukegus/termix:latest container_name: termix restart: unless-stopped ports: - "8080:8080" volumes: - termix-data:/app/data environment: PORT: "8080" depends_on: - guacd networks: - termix-net guacd: image: guacamole/guacd:latest container_name: guacd restart: unless-stopped ports: - "4822:4822" networks: - termix-net volumes: termix-data: driver: local networks: termix-net: driver: bridge ``` # Nhà Tài Trợ

DigitalOcean          Crowdin          Crowdin          Crowdin

# Hỗ Trợ Nếu bạn cần trợ giúp hoặc muốn yêu cầu tính năng với Termix, hãy truy cập trang [Vấn Đề](https://github.com/Termix-SSH/Support/issues), đăng nhập và nhấn `New Issue`. Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết bằng tiếng Anh. Bạn cũng có thể tham gia máy chủ [Discord](https://discord.gg/jVQGdvHDrf) và truy cập kênh hỗ trợ, tuy nhiên thời gian phản hồi có thể lâu hơn. # Ảnh Chụp Màn Hình [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)

Termix Demo 1 Termix Demo 2

Termix Demo 3 Termix Demo 4

Termix Demo 5 Termix Demo 6

Termix Demo 7 Termix Demo 8

Termix Demo 9 Termix Demo 10

Termix Demo 11 Termix Demo 12

Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng. # Giấy Phép Được phân phối theo Giấy Phép Apache Phiên Bản 2.0. Xem LICENSE để biết thêm thông tin. ================================================ FILE: src/backend/dashboard.ts ================================================ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import { getDb, DatabaseSaveTrigger } from "./database/db/index.js"; import { recentActivity, hosts, hostAccess, dashboardPreferences, } from "./database/db/schema.js"; import { eq, and, desc, sql } from "drizzle-orm"; import { dashboardLogger } from "./utils/logger.js"; import { SimpleDBOps } from "./utils/simple-db-ops.js"; import { AuthManager } from "./utils/auth-manager.js"; import type { AuthenticatedRequest } from "../types/index.js"; const app = express(); const authManager = AuthManager.getInstance(); const serverStartTime = Date.now(); const activityRateLimiter = new Map(); const RATE_LIMIT_MS = 1000; app.use( cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (allowedOrigins.includes(origin)) { return callback(null, true); } if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Authorization", "User-Agent", "X-Electron-App", ], }), ); 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()); /** * @openapi * /uptime: * get: * summary: Get server uptime * description: Returns the uptime of the server in various formats. * tags: * - Dashboard * responses: * 200: * description: Server uptime information. * content: * application/json: * schema: * type: object * properties: * uptimeMs: * type: number * uptimeSeconds: * type: number * formatted: * type: string * 500: * description: Failed to get uptime. */ app.get("/uptime", async (req, res) => { try { const uptimeMs = Date.now() - serverStartTime; const uptimeSeconds = Math.floor(uptimeMs / 1000); const days = Math.floor(uptimeSeconds / 86400); const hours = Math.floor((uptimeSeconds % 86400) / 3600); const minutes = Math.floor((uptimeSeconds % 3600) / 60); res.json({ uptimeMs, uptimeSeconds, formatted: `${days}d ${hours}h ${minutes}m`, }); } catch (err) { dashboardLogger.error("Failed to get uptime", err); res.status(500).json({ error: "Failed to get uptime" }); } }); /** * @openapi * /activity/recent: * get: * summary: Get recent activity * description: Fetches the most recent activities for the authenticated user. * tags: * - Dashboard * parameters: * - in: query * name: limit * schema: * type: integer * description: The maximum number of activities to return. * responses: * 200: * description: A list of recent activities. * 401: * description: Session expired. * 500: * description: Failed to get recent activity. */ app.get("/activity/recent", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const limit = Number(req.query.limit) || 20; const activities = await SimpleDBOps.select( getDb() .select() .from(recentActivity) .where(eq(recentActivity.userId, userId)) .orderBy(desc(recentActivity.timestamp)) .limit(limit), "recent_activity", userId, ); res.json(activities); } catch (err) { dashboardLogger.error("Failed to get recent activity", err); res.status(500).json({ error: "Failed to get recent activity" }); } }); /** * @openapi * /activity/log: * post: * summary: Log a new activity * description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited. * tags: * - Dashboard * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * type: * type: string * enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp] * hostId: * type: integer * hostName: * type: string * responses: * 200: * description: Activity logged successfully or rate-limited. * 400: * description: Invalid request body. * 401: * description: Session expired. * 404: * description: Host not found or access denied. * 500: * description: Failed to log activity. */ app.post("/activity/log", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const { type, hostId, hostName } = req.body; if (!type || !hostId || !hostName) { return res.status(400).json({ error: "Missing required fields: type, hostId, hostName", }); } if ( ![ "terminal", "file_manager", "server_stats", "tunnel", "docker", "telnet", "vnc", "rdp", ].includes(type) ) { return res.status(400).json({ error: "Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'", }); } const rateLimitKey = `${userId}:${hostId}:${type}`; const now = Date.now(); const lastLogged = activityRateLimiter.get(rateLimitKey); if (lastLogged && now - lastLogged < RATE_LIMIT_MS) { return res.json({ message: "Activity already logged recently (rate limited)", }); } activityRateLimiter.set(rateLimitKey, now); if (activityRateLimiter.size > 10000) { const entriesToDelete: string[] = []; for (const [key, timestamp] of activityRateLimiter.entries()) { if (now - timestamp > RATE_LIMIT_MS * 2) { entriesToDelete.push(key); } } entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); } const ownedHosts = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (ownedHosts.length === 0) { const sharedHosts = await getDb() .select() .from(hostAccess) .where( and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)), ); if (sharedHosts.length === 0) { return res .status(404) .json({ error: "Host not found or access denied" }); } } const result = (await SimpleDBOps.insert( recentActivity, "recent_activity", { userId, type, hostId, hostName, }, userId, )) as unknown as { id: number }; const allActivities = await SimpleDBOps.select( getDb() .select() .from(recentActivity) .where(eq(recentActivity.userId, userId)) .orderBy(desc(recentActivity.timestamp)), "recent_activity", userId, ); if (allActivities.length > 100) { const toDelete = allActivities.slice(100); for (let i = 0; i < toDelete.length; i++) { await SimpleDBOps.delete(recentActivity, "recent_activity", userId); } } res.json({ message: "Activity logged", id: result.id }); } catch (err) { dashboardLogger.error("Failed to log activity", err); res.status(500).json({ error: "Failed to log activity" }); } }); /** * @openapi * /activity/reset: * delete: * summary: Reset recent activity * description: Clears all recent activity for the authenticated user. * tags: * - Dashboard * responses: * 200: * description: Recent activity cleared. * 401: * description: Session expired. * 500: * description: Failed to reset activity. */ app.delete("/activity/reset", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } await SimpleDBOps.delete( recentActivity, "recent_activity", eq(recentActivity.userId, userId), ); dashboardLogger.success("Recent activity cleared", { operation: "reset_recent_activity", userId, }); res.json({ message: "Recent activity cleared" }); } catch (err) { dashboardLogger.error("Failed to reset activity", err); res.status(500).json({ error: "Failed to reset activity" }); } }); /** * @openapi * /dashboard/preferences: * get: * summary: Get dashboard layout preferences * description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout. * tags: * - Dashboard * responses: * 200: * description: Dashboard preferences retrieved * content: * application/json: * schema: * type: object * properties: * cards: * type: array * items: * type: object * properties: * id: * type: string * enabled: * type: boolean * order: * type: integer * 401: * description: Session expired * 500: * description: Failed to get preferences */ app.get("/dashboard/preferences", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const preferences = await getDb() .select() .from(dashboardPreferences) .where(eq(dashboardPreferences.userId, userId)); if (preferences.length === 0) { const defaultLayout = { cards: [ { id: "server_overview", enabled: true, order: 1 }, { id: "recent_activity", enabled: true, order: 2 }, { id: "network_graph", enabled: false, order: 3 }, { id: "quick_actions", enabled: true, order: 4 }, { id: "server_stats", enabled: true, order: 5 }, ], }; return res.json(defaultLayout); } const layout = JSON.parse(preferences[0].layout as string); res.json(layout); } catch (err) { dashboardLogger.error("Failed to get dashboard preferences", err); res.status(500).json({ error: "Failed to get dashboard preferences" }); } }); /** * @openapi * /dashboard/preferences: * post: * summary: Save dashboard layout preferences * description: Saves or updates the user's customized dashboard layout settings. * tags: * - Dashboard * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * cards: * type: array * items: * type: object * properties: * id: * type: string * enabled: * type: boolean * order: * type: integer * responses: * 200: * description: Preferences saved successfully * 400: * description: Invalid request body * 401: * description: Session expired * 500: * description: Failed to save preferences */ app.post("/dashboard/preferences", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const { cards } = req.body; if (!cards || !Array.isArray(cards)) { return res.status(400).json({ error: "Invalid request body. Expected { cards: Array }", }); } const layout = JSON.stringify({ cards }); const existing = await getDb() .select() .from(dashboardPreferences) .where(eq(dashboardPreferences.userId, userId)); if (existing.length > 0) { await getDb() .update(dashboardPreferences) .set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(eq(dashboardPreferences.userId, userId)); } else { await getDb().insert(dashboardPreferences).values({ userId, layout }); } await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated"); dashboardLogger.success("Dashboard preferences saved", { operation: "save_dashboard_preferences", userId, }); res.json({ success: true, message: "Dashboard preferences saved" }); } catch (err) { dashboardLogger.error("Failed to save dashboard preferences", err); res.status(500).json({ error: "Failed to save dashboard preferences" }); } }); const PORT = 30006; app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { dashboardLogger.error("Failed to initialize AuthManager", err, { operation: "auth_init_error", }); } }); ================================================ FILE: src/backend/database/database.ts ================================================ import express from "express"; import bodyParser from "body-parser"; import multer from "multer"; import cookieParser from "cookie-parser"; import userRoutes from "./routes/users.js"; import hostRoutes from "./routes/host.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; import terminalRoutes from "./routes/terminal.js"; import guacamoleRoutes from "../guacamole/routes.js"; import networkTopologyRoutes from "./routes/network-topology.js"; import rbacRoutes from "./routes/rbac.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; import path from "path"; import os from "os"; import "dotenv/config"; import { databaseLogger, apiLogger } from "../utils/logger.js"; import { AuthManager } from "../utils/auth-manager.js"; import { DataCrypto } from "../utils/data-crypto.js"; import { DatabaseFileEncryption } from "../utils/database-file-encryption.js"; import { DatabaseMigration } from "../utils/database-migration.js"; import { UserDataExport } from "../utils/user-data-export.js"; import { AutoSSLSetup } from "../utils/auto-ssl-setup.js"; import { eq, and } from "drizzle-orm"; import { parseUserAgent } from "../utils/user-agent-parser.js"; import { getProxyAgent } from "../utils/proxy-agent.js"; import { users, hosts, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, sshCredentialUsage, settings, } from "./db/schema.js"; import type { CacheEntry, GitHubRelease, GitHubAPIResponse, AuthenticatedRequest, } from "../../types/index.js"; import { getDb, DatabaseSaveTrigger } from "./db/index.js"; import Database from "better-sqlite3"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.set("trust proxy", true); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); app.use( cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (allowedOrigins.includes(origin)) { return callback(null, true); } if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Authorization", "User-Agent", "X-Electron-App", "Accept", "Origin", ], }), ); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, "uploads/"); }, filename: (req, file, cb) => { const timestamp = Date.now(); cb(null, `${timestamp}-${file.originalname}`); }, }); const upload = multer({ storage: storage, limits: { fileSize: 1024 * 1024 * 1024, }, fileFilter: (req, file, cb) => { if ( file.originalname.endsWith(".termix-export.sqlite") || file.originalname.endsWith(".sqlite") ) { cb(null, true); } else { cb(new Error("Only .termix-export.sqlite files are allowed")); } }, }); class GitHubCache { private cache: Map = new Map(); private readonly CACHE_DURATION = 30 * 60 * 1000; set(key: string, data: T): void { const now = Date.now(); this.cache.set(key, { data, timestamp: now, expiresAt: now + this.CACHE_DURATION, }); } get(key: string): T | null { const entry = this.cache.get(key); if (!entry) { return null; } if (Date.now() > entry.expiresAt) { this.cache.delete(key); return null; } return entry.data as T; } } const githubCache = new GitHubCache(); const GITHUB_API_BASE = "https://api.github.com"; const REPO_OWNER = "Termix-SSH"; const REPO_NAME = "Termix"; async function fetchGitHubAPI( endpoint: string, cacheKey: string, ): Promise> { const cachedEntry = githubCache.get>(cacheKey); if (cachedEntry) { return { data: cachedEntry.data, cached: true, cache_age: Date.now() - cachedEntry.timestamp, }; } try { const url = `${GITHUB_API_BASE}${endpoint}`; const response = await fetch(url, { headers: { Accept: "application/vnd.github+json", "User-Agent": "TermixUpdateChecker/1.0", "X-GitHub-Api-Version": "2022-11-28", }, agent: getProxyAgent(url), }); if (!response.ok) { throw new Error( `GitHub API error: ${response.status} ${response.statusText}`, ); } const data = (await response.json()) as T; const cacheData: CacheEntry = { data, timestamp: Date.now(), expiresAt: Date.now() + 30 * 60 * 1000, }; githubCache.set(cacheKey, cacheData); return { data: data, cached: false, }; } catch (error) { databaseLogger.error(`Failed to fetch from GitHub API`, error, { operation: "github_api", endpoint, }); throw error; } } app.use(bodyParser.json({ limit: "1gb" })); app.use(bodyParser.urlencoded({ limit: "1gb", extended: true })); app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" })); app.use(cookieParser()); app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); /** * @openapi * /health: * get: * summary: Health check * description: Returns the health status of the server. * tags: * - General * responses: * 200: * description: Server is healthy. * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: ok */ app.get("/health", (req, res) => { res.json({ status: "ok" }); }); /** * @openapi * /version: * get: * summary: Get version information * description: Returns the local and remote version of the application. * tags: * - General * responses: * 200: * description: Version information. * 404: * description: Local version not set. * 500: * description: Fetch error. */ app.get("/version", authenticateJWT, async (req, res) => { let localVersion = process.env.VERSION; if (!localVersion) { const versionSources = [ () => { try { const packagePath = path.resolve(process.cwd(), "package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); return packageJson.version; } catch { return null; } }, () => { try { const packagePath = path.resolve("/app", "package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); return packageJson.version; } catch { return null; } }, () => { try { const packagePath = path.resolve(__dirname, "../../../package.json"); const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); return packageJson.version; } catch { return null; } }, ]; for (const getVersion of versionSources) { try { const foundVersion = getVersion(); if (foundVersion && foundVersion !== "unknown") { localVersion = foundVersion; break; } } catch { continue; } } } if (!localVersion) { databaseLogger.error("No version information available", undefined, { operation: "version_check", }); return res.status(404).send("Local Version Not Set"); } try { const cacheKey = "latest_release"; const releaseData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`, cacheKey, ); const rawTag = releaseData.data.tag_name || releaseData.data.name || ""; const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/); const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null; if (!remoteVersion) { databaseLogger.warn("Remote version not found in GitHub response", { operation: "version_check", rawTag, }); return res.status(401).send("Remote Version Not Found"); } const isUpToDate = localVersion === remoteVersion; const response = { status: isUpToDate ? "up_to_date" : "requires_update", localVersion: localVersion, version: remoteVersion, latest_release: { tag_name: releaseData.data.tag_name, name: releaseData.data.name, published_at: releaseData.data.published_at, html_url: releaseData.data.html_url, }, cached: releaseData.cached, cache_age: releaseData.cache_age, }; res.json(response); } catch (err) { databaseLogger.error("Version check failed", err, { operation: "version_check", }); res.status(500).send("Fetch Error"); } }); /** * @openapi * /releases/rss: * get: * summary: Get releases in RSS format * description: Returns the latest releases from the GitHub repository in an RSS-like JSON format. * tags: * - General * parameters: * - in: query * name: page * schema: * type: integer * description: The page number of the releases to fetch. * - in: query * name: per_page * schema: * type: integer * description: The number of releases to fetch per page. * responses: * 200: * description: Releases in RSS format. * 500: * description: Failed to generate RSS format. */ app.get("/releases/rss", authenticateJWT, async (req, res) => { try { const page = parseInt(req.query.page as string) || 1; const per_page = Math.min( parseInt(req.query.per_page as string) || 20, 100, ); const cacheKey = `releases_rss_${page}_${per_page}`; const releasesData = await fetchGitHubAPI( `/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`, cacheKey, ); const rssItems = releasesData.data.map((release) => ({ id: release.id, title: release.name || release.tag_name, description: release.body, link: release.html_url, pubDate: release.published_at, version: release.tag_name, isPrerelease: release.prerelease, isDraft: release.draft, assets: release.assets.map((asset) => ({ name: asset.name, size: asset.size, download_count: asset.download_count, download_url: asset.browser_download_url, })), })); const response = { feed: { title: `${REPO_NAME} Releases`, description: `Latest releases from ${REPO_NAME} repository`, link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`, updated: new Date().toISOString(), }, items: rssItems, total_count: rssItems.length, cached: releasesData.cached, cache_age: releasesData.cache_age, }; res.json(response); } catch (error) { databaseLogger.error("Failed to generate RSS format", error, { operation: "rss_releases", }); res.status(500).json({ error: "Failed to generate RSS format", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * @openapi * /encryption/status: * get: * summary: Get encryption status * description: Returns the security status of the application. * tags: * - Encryption * responses: * 200: * description: Security status. * 500: * description: Failed to get security status. */ app.get("/encryption/status", requireAdmin, async (req, res) => { try { const securityStatus = { initialized: true, system: { hasSecret: true, isValid: true }, activeSessions: {}, activeSessionCount: 0, }; res.json({ security: securityStatus, version: "v2-kek-dek", }); } catch (error) { apiLogger.error("Failed to get security status", error, { operation: "security_status", }); res.status(500).json({ error: "Failed to get security status" }); } }); /** * @openapi * /encryption/initialize: * post: * summary: Initialize security system * description: Initializes the security system for the application. * tags: * - Encryption * responses: * 200: * description: Security system initialized successfully. * 500: * description: Failed to initialize security system. */ app.post("/encryption/initialize", requireAdmin, async (req, res) => { try { const authManager = AuthManager.getInstance(); const isValid = true; if (!isValid) { await authManager.initialize(); } res.json({ success: true, message: "Security system initialized successfully", version: "v2-kek-dek", note: "User data encryption will be set up when users log in", }); } catch (error) { apiLogger.error("Failed to initialize security system", error, { operation: "security_init_api_failed", }); res.status(500).json({ error: "Failed to initialize security system" }); } }); /** * @openapi * /encryption/regenerate: * post: * summary: Regenerate JWT secret * description: Regenerates the system JWT secret. This will invalidate all existing JWT tokens. * tags: * - Encryption * responses: * 200: * description: System JWT secret regenerated. * 500: * description: Failed to regenerate JWT secret. */ app.post("/encryption/regenerate", requireAdmin, async (req, res) => { try { apiLogger.warn("System JWT secret regenerated via API", { operation: "jwt_regenerate_api", }); res.json({ success: true, message: "System JWT secret regenerated", warning: "All existing JWT tokens are now invalid - users must re-authenticate", note: "User data encryption keys are protected by passwords and cannot be regenerated", }); } catch (error) { apiLogger.error("Failed to regenerate JWT secret", error, { operation: "jwt_regenerate_failed", }); res.status(500).json({ error: "Failed to regenerate JWT secret" }); } }); /** * @openapi * /encryption/regenerate-jwt: * post: * summary: Regenerate JWT secret * description: Regenerates the JWT secret. This will invalidate all existing JWT tokens. * tags: * - Encryption * responses: * 200: * description: New JWT secret generated. * 500: * description: Failed to regenerate JWT secret. */ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => { try { apiLogger.warn("JWT secret regenerated via API", { operation: "jwt_secret_regenerate_api", }); res.json({ success: true, message: "New JWT secret generated", warning: "All existing JWT tokens are now invalid - users must re-authenticate", }); } catch (error) { apiLogger.error("Failed to regenerate JWT secret", error, { operation: "jwt_secret_regenerate_failed", }); res.status(500).json({ error: "Failed to regenerate JWT secret" }); } }); /** * @openapi * /database/export: * post: * summary: Export user data * description: Exports the user's data as a SQLite database file. * tags: * - Database * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * password: * type: string * responses: * 200: * description: User data exported successfully. * 400: * description: Password required for export. * 401: * description: Invalid password. * 500: * description: Failed to export user data. */ app.post("/database/export", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; const deviceInfo = parseUserAgent(req); const user = await getDb().select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const isOidcUser = !!user[0].isOidc; if (!isOidcUser) { if (!password) { return res.status(400).json({ error: "Password required for export", code: "PASSWORD_REQUIRED", }); } const unlocked = await authManager.authenticateUser( userId, password, deviceInfo.type, ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } } else if (!DataCrypto.getUserDataKey(userId)) { const oidcUnlocked = await authManager.authenticateOIDCUser( userId, deviceInfo.type, ); if (!oidcUnlocked) { return res.status(403).json({ error: "Failed to unlock user data with SSO credentials", }); } } apiLogger.info("Exporting user data as SQLite", { operation: "user_data_sqlite_export_api", userId, }); const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data not unlocked"); } const tempDir = process.env.NODE_ENV === "production" ? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports") : path.join(os.tmpdir(), "termix-exports"); try { if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } } catch (dirError) { apiLogger.error("Failed to create temp directory", dirError, { operation: "export_temp_dir_error", tempDir, }); throw new Error(`Failed to create temp directory: ${dirError.message}`); } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `termix-export-${user[0].username}-${timestamp}.sqlite`; const tempPath = path.join(tempDir, filename); apiLogger.info("Creating export database", { operation: "export_db_creation", userId, tempPath, }); const exportDb = new Database(tempPath); try { exportDb.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_oidc INTEGER NOT NULL DEFAULT 0, oidc_identifier TEXT, client_id TEXT, client_secret TEXT, issuer_url TEXT, authorization_url TEXT, token_url TEXT, identifier_path TEXT, name_path TEXT, scopes TEXT DEFAULT 'openid email profile', totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT 0, totp_backup_codes TEXT ); CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE ssh_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT, ip TEXT NOT NULL, port INTEGER NOT NULL, username TEXT NOT NULL, folder TEXT, tags TEXT, pin INTEGER NOT NULL DEFAULT 0, auth_type TEXT NOT NULL, force_keyboard_interactive TEXT, password TEXT, key TEXT, key_password TEXT, key_type TEXT, sudo_password TEXT, autostart_password TEXT, autostart_key TEXT, autostart_key_password TEXT, credential_id INTEGER, override_credential_username INTEGER, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, jump_hosts TEXT, enable_file_manager INTEGER NOT NULL DEFAULT 1, enable_docker INTEGER NOT NULL DEFAULT 0, show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1, show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0, show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0, show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0, show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0, default_path TEXT, stats_config TEXT, terminal_config TEXT, quick_actions TEXT, notes TEXT, use_socks5 INTEGER, socks5_host TEXT, socks5_port INTEGER, socks5_username TEXT, socks5_password TEXT, socks5_proxy_chain TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ssh_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, folder TEXT, tags TEXT, auth_type TEXT NOT NULL, username TEXT NOT NULL, password TEXT, key TEXT, private_key TEXT, public_key TEXT, key_password TEXT, key_type TEXT, detected_key_type TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE file_manager_recent ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE file_manager_pinned ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE file_manager_shortcuts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE dismissed_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, alert_id TEXT NOT NULL, dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ssh_credential_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); const userRecord = user[0]; const insertUser = exportDb.prepare(` INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); insertUser.run( userRecord.id, userRecord.username, "[EXPORTED_USER_NO_PASSWORD]", userRecord.isAdmin ? 1 : 0, userRecord.isOidc ? 1 : 0, userRecord.oidcIdentifier || null, userRecord.clientId || null, userRecord.clientSecret || null, userRecord.issuerUrl || null, userRecord.authorizationUrl || null, userRecord.tokenUrl || null, userRecord.identifierPath || null, userRecord.namePath || null, userRecord.scopes || null, userRecord.totpSecret || null, userRecord.totpEnabled ? 1 : 0, userRecord.totpBackupCodes || null, ); const sshHosts = await getDb() .select() .from(hosts) .where(eq(hosts.userId, userId)); const insertHost = exportDb.prepare(` INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const host of sshHosts) { const decrypted = DataCrypto.decryptRecord( "ssh_data", host, userId, userDataKey, ); insertHost.run( decrypted.id, decrypted.userId, decrypted.name || null, decrypted.ip, decrypted.port, decrypted.username, decrypted.folder || null, decrypted.tags || null, decrypted.pin ? 1 : 0, decrypted.authType, decrypted.forceKeyboardInteractive || null, decrypted.password || null, decrypted.key || null, decrypted.keyPassword || null, decrypted.keyType || null, decrypted.sudoPassword || null, decrypted.autostartPassword || null, decrypted.autostartKey || null, decrypted.autostartKeyPassword || null, decrypted.credentialId || null, decrypted.overrideCredentialUsername ? 1 : 0, decrypted.enableTerminal ? 1 : 0, decrypted.enableTunnel ? 1 : 0, decrypted.tunnelConnections || null, decrypted.jumpHosts || null, decrypted.enableFileManager ? 1 : 0, decrypted.enableDocker ? 1 : 0, decrypted.showTerminalInSidebar ? 1 : 0, decrypted.showFileManagerInSidebar ? 1 : 0, decrypted.showTunnelInSidebar ? 1 : 0, decrypted.showDockerInSidebar ? 1 : 0, decrypted.showServerStatsInSidebar ? 1 : 0, decrypted.defaultPath || null, decrypted.statsConfig || null, decrypted.terminalConfig || null, decrypted.quickActions || null, decrypted.notes || null, decrypted.useSocks5 ? 1 : 0, decrypted.socks5Host || null, decrypted.socks5Port || null, decrypted.socks5Username || null, decrypted.socks5Password || null, decrypted.socks5ProxyChain || null, decrypted.createdAt, decrypted.updatedAt, ); } const credentials = await getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.userId, userId)); const insertCred = exportDb.prepare(` INSERT INTO ssh_credentials (id, user_id, name, description, folder, tags, auth_type, username, password, key, private_key, public_key, key_password, key_type, detected_key_type, usage_count, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const cred of credentials) { const decrypted = DataCrypto.decryptRecord( "ssh_credentials", cred, userId, userDataKey, ); insertCred.run( decrypted.id, decrypted.userId, decrypted.name, decrypted.description || null, decrypted.folder || null, decrypted.tags || null, decrypted.authType, decrypted.username, decrypted.password || null, decrypted.key || null, decrypted.privateKey || null, decrypted.publicKey || null, decrypted.keyPassword || null, decrypted.keyType || null, decrypted.detectedKeyType || null, decrypted.usageCount || 0, decrypted.lastUsed || null, decrypted.createdAt, decrypted.updatedAt, ); } const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([ getDb() .select() .from(fileManagerRecent) .where(eq(fileManagerRecent.userId, userId)), getDb() .select() .from(fileManagerPinned) .where(eq(fileManagerPinned.userId, userId)), getDb() .select() .from(fileManagerShortcuts) .where(eq(fileManagerShortcuts.userId, userId)), ]); const insertRecent = exportDb.prepare(` INSERT INTO file_manager_recent (id, user_id, host_id, name, path, last_opened) VALUES (?, ?, ?, ?, ?, ?) `); for (const item of recentFiles) { insertRecent.run( item.id, item.userId, item.hostId, item.name, item.path, item.lastOpened, ); } const insertPinned = exportDb.prepare(` INSERT INTO file_manager_pinned (id, user_id, host_id, name, path, pinned_at) VALUES (?, ?, ?, ?, ?, ?) `); for (const item of pinnedFiles) { insertPinned.run( item.id, item.userId, item.hostId, item.name, item.path, item.pinnedAt, ); } const insertShortcut = exportDb.prepare(` INSERT INTO file_manager_shortcuts (id, user_id, host_id, name, path, created_at) VALUES (?, ?, ?, ?, ?, ?) `); for (const item of shortcuts) { insertShortcut.run( item.id, item.userId, item.hostId, item.name, item.path, item.createdAt, ); } const alerts = await getDb() .select() .from(dismissedAlerts) .where(eq(dismissedAlerts.userId, userId)); const insertAlert = exportDb.prepare(` INSERT INTO dismissed_alerts (id, user_id, alert_id, dismissed_at) VALUES (?, ?, ?, ?) `); for (const alert of alerts) { insertAlert.run( alert.id, alert.userId, alert.alertId, alert.dismissedAt, ); } const usage = await getDb() .select() .from(sshCredentialUsage) .where(eq(sshCredentialUsage.userId, userId)); const insertUsage = exportDb.prepare(` INSERT INTO ssh_credential_usage (id, credential_id, host_id, user_id, used_at) VALUES (?, ?, ?, ?, ?) `); for (const item of usage) { insertUsage.run( item.id, item.credentialId, item.hostId, item.userId, item.usedAt, ); } const settingsData = await getDb().select().from(settings); const insertSetting = exportDb.prepare(` INSERT INTO settings (key, value) VALUES (?, ?) `); for (const setting of settingsData) { insertSetting.run(setting.key, setting.value); } } finally { exportDb.close(); } res.setHeader("Content-Type", "application/x-sqlite3"); res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); const fileStream = fs.createReadStream(tempPath); fileStream.on("error", (streamError) => { apiLogger.error("File stream error during export", streamError, { operation: "export_file_stream_error", userId, tempPath, }); if (!res.headersSent) { res.status(500).json({ error: "Failed to stream export file", details: streamError.message, }); } }); fileStream.on("end", () => { apiLogger.success("User data exported as SQLite successfully", { operation: "user_data_sqlite_export_success", userId, filename, }); fs.unlink(tempPath, (err) => { if (err) { apiLogger.warn("Failed to clean up export file", { operation: "export_cleanup_failed", path: tempPath, error: err.message, }); } }); }); fileStream.pipe(res); } catch (error) { apiLogger.error("User data SQLite export failed", error, { operation: "user_data_sqlite_export_failed", }); res.status(500).json({ error: "Failed to export user data", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * @openapi * /database/import: * post: * summary: Import user data * description: Imports user data from a SQLite database file. * tags: * - Database * requestBody: * required: true * content: * multipart/form-data: * schema: * type: object * properties: * file: * type: string * format: binary * password: * type: string * responses: * 200: * description: Incremental import completed successfully. * 400: * description: No file uploaded or password required for import. * 401: * description: Invalid password. * 500: * description: Failed to import SQLite data. */ app.post( "/database/import", authenticateJWT, upload.single("file"), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; const mainDb = getDb(); const deviceInfo = parseUserAgent(req); const userRecords = await mainDb .select() .from(users) .where(eq(users.id, userId)); if (!userRecords || userRecords.length === 0) { return res.status(404).json({ error: "User not found" }); } const isOidcUser = !!userRecords[0].isOidc; if (!isOidcUser) { if (!password) { return res.status(400).json({ error: "Password required for import", code: "PASSWORD_REQUIRED", }); } const unlocked = await authManager.authenticateUser( userId, password, deviceInfo.type, ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } } else if (!DataCrypto.getUserDataKey(userId)) { const oidcUnlocked = await authManager.authenticateOIDCUser( userId, deviceInfo.type, ); if (!oidcUnlocked) { return res.status(403).json({ error: "Failed to unlock user data with SSO credentials", }); } } apiLogger.info("Importing SQLite data", { operation: "sqlite_import_api", userId, filename: req.file.originalname, fileSize: req.file.size, mimetype: req.file.mimetype, }); let userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey && isOidcUser) { const oidcUnlocked = await authManager.authenticateOIDCUser( userId, deviceInfo.type, ); if (oidcUnlocked) { userDataKey = DataCrypto.getUserDataKey(userId); } } if (!userDataKey) { throw new Error("User data not unlocked"); } if (!fs.existsSync(req.file.path)) { return res.status(400).json({ error: "Uploaded file not found", details: "File was not properly uploaded", }); } const fileHeader = Buffer.alloc(16); const fd = fs.openSync(req.file.path, "r"); fs.readSync(fd, fileHeader, 0, 16, 0); fs.closeSync(fd); const sqliteHeader = "SQLite format 3"; if (fileHeader.toString("utf8", 0, 15) !== sqliteHeader) { return res.status(400).json({ error: "Invalid file format - not a SQLite database", details: `Expected SQLite file, got file starting with: ${fileHeader.toString("utf8", 0, 15)}`, }); } let importDb; try { importDb = new Database(req.file.path, { readonly: true }); importDb .prepare("SELECT name FROM sqlite_master WHERE type='table'") .all(); } catch (sqliteError) { return res.status(400).json({ error: "Failed to open SQLite database", details: sqliteError.message, }); } const result = { success: false, summary: { sshHostsImported: 0, sshCredentialsImported: 0, fileManagerItemsImported: 0, dismissedAlertsImported: 0, credentialUsageImported: 0, settingsImported: 0, skippedItems: 0, errors: [], }, }; try { try { const importedHosts = importDb .prepare("SELECT * FROM ssh_data") .all(); for (const host of importedHosts) { try { const existing = await mainDb .select() .from(hosts) .where( and( eq(hosts.userId, userId), eq(hosts.ip, host.ip), eq(hosts.port, host.port), eq(hosts.username, host.username), ), ); if (existing.length > 0) { result.summary.skippedItems++; continue; } const hostData = { userId: userId, name: host.name, ip: host.ip, port: host.port, username: host.username, folder: host.folder, tags: host.tags, pin: Boolean(host.pin), authType: host.auth_type, forceKeyboardInteractive: host.force_keyboard_interactive, password: host.password, key: host.key, keyPassword: host.key_password, keyType: host.key_type, sudoPassword: host.sudo_password, autostartPassword: host.autostart_password, autostartKey: host.autostart_key, autostartKeyPassword: host.autostart_key_password, credentialId: host.credential_id || null, overrideCredentialUsername: Boolean( host.override_credential_username, ), enableTerminal: Boolean(host.enable_terminal), enableTunnel: Boolean(host.enable_tunnel), tunnelConnections: host.tunnel_connections, jumpHosts: host.jump_hosts, enableFileManager: Boolean(host.enable_file_manager), enableDocker: Boolean(host.enable_docker), showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar), showFileManagerInSidebar: Boolean( host.show_file_manager_in_sidebar, ), showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar), showDockerInSidebar: Boolean(host.show_docker_in_sidebar), showServerStatsInSidebar: Boolean( host.show_server_stats_in_sidebar, ), defaultPath: host.default_path, statsConfig: host.stats_config, terminalConfig: host.terminal_config, quickActions: host.quick_actions, notes: host.notes, useSocks5: Boolean(host.use_socks5), socks5Host: host.socks5_host, socks5Port: host.socks5_port, socks5Username: host.socks5_username, socks5Password: host.socks5_password, socks5ProxyChain: host.socks5_proxy_chain, createdAt: host.created_at || new Date().toISOString(), updatedAt: new Date().toISOString(), }; const encrypted = DataCrypto.encryptRecord( "ssh_data", hostData, userId, userDataKey, ); await mainDb.insert(hosts).values(encrypted); result.summary.sshHostsImported++; } catch (hostError) { result.summary.errors.push( `SSH host import error: ${hostError.message}`, ); } } } catch { apiLogger.info("ssh_data table not found in import file, skipping"); } try { const importedCreds = importDb .prepare("SELECT * FROM ssh_credentials") .all(); for (const cred of importedCreds) { try { const existing = await mainDb .select() .from(sshCredentials) .where( and( eq(sshCredentials.userId, userId), eq(sshCredentials.name, cred.name), eq(sshCredentials.username, cred.username), ), ); if (existing.length > 0) { result.summary.skippedItems++; continue; } const credData = { userId: userId, name: cred.name, description: cred.description, folder: cred.folder, tags: cred.tags, authType: cred.auth_type, username: cred.username, password: cred.password, key: cred.key, privateKey: cred.private_key, publicKey: cred.public_key, keyPassword: cred.key_password, keyType: cred.key_type, detectedKeyType: cred.detected_key_type, usageCount: cred.usage_count || 0, lastUsed: cred.last_used, createdAt: cred.created_at || new Date().toISOString(), updatedAt: new Date().toISOString(), }; const encrypted = DataCrypto.encryptRecord( "ssh_credentials", credData, userId, userDataKey, ); await mainDb.insert(sshCredentials).values(encrypted); result.summary.sshCredentialsImported++; } catch (credError) { result.summary.errors.push( `SSH credential import error: ${credError.message}`, ); } } } catch { apiLogger.info( "ssh_credentials table not found in import file, skipping", ); } const fileManagerTables = [ { table: "file_manager_recent", schema: fileManagerRecent, key: "fileManagerItemsImported", }, { table: "file_manager_pinned", schema: fileManagerPinned, key: "fileManagerItemsImported", }, { table: "file_manager_shortcuts", schema: fileManagerShortcuts, key: "fileManagerItemsImported", }, ]; for (const { table, schema, key } of fileManagerTables) { try { const importedItems = importDb .prepare(`SELECT * FROM ${table}`) .all(); for (const item of importedItems) { try { const existing = await mainDb .select() .from(schema) .where( and( eq(schema.userId, userId), eq(schema.path, item.path), eq(schema.name, item.name), ), ); if (existing.length > 0) { result.summary.skippedItems++; continue; } const itemData = { userId: userId, hostId: item.host_id, name: item.name, path: item.path, ...(table === "file_manager_recent" && { lastOpened: item.last_opened, }), ...(table === "file_manager_pinned" && { pinnedAt: item.pinned_at, }), ...(table === "file_manager_shortcuts" && { createdAt: item.created_at, }), }; await mainDb.insert(schema).values(itemData); result.summary[key]++; } catch (itemError) { result.summary.errors.push( `${table} import error: ${itemError.message}`, ); } } } catch { apiLogger.info(`${table} table not found in import file, skipping`); } } try { const importedAlerts = importDb .prepare("SELECT * FROM dismissed_alerts") .all(); for (const alert of importedAlerts) { try { const existing = await mainDb .select() .from(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.alertId, alert.alert_id), ), ); if (existing.length > 0) { result.summary.skippedItems++; continue; } await mainDb.insert(dismissedAlerts).values({ userId: userId, alertId: alert.alert_id, dismissedAt: alert.dismissed_at || new Date().toISOString(), }); result.summary.dismissedAlertsImported++; } catch (alertError) { result.summary.errors.push( `Dismissed alert import error: ${alertError.message}`, ); } } } catch { apiLogger.info( "dismissed_alerts table not found in import file, skipping", ); } const targetUser = await mainDb .select() .from(users) .where(eq(users.id, userId)); if (targetUser.length > 0 && targetUser[0].isAdmin) { try { const importedSettings = importDb .prepare("SELECT * FROM settings") .all(); for (const setting of importedSettings) { try { const existing = await mainDb .select() .from(settings) .where(eq(settings.key, setting.key)); if (existing.length > 0) { await mainDb .update(settings) .set({ value: setting.value }) .where(eq(settings.key, setting.key)); result.summary.settingsImported++; } else { await mainDb.insert(settings).values({ key: setting.key, value: setting.value, }); result.summary.settingsImported++; } } catch (settingError) { result.summary.errors.push( `Setting import error (${setting.key}): ${settingError.message}`, ); } } } catch { apiLogger.info("settings table not found in import file, skipping"); } } else { apiLogger.info( "Settings import skipped - only admin users can import settings", ); } result.success = true; try { await DatabaseSaveTrigger.forceSave("database_import"); } catch (saveError) { apiLogger.error( "Failed to persist imported data to disk", saveError, { operation: "import_force_save_failed", userId, }, ); } } finally { if (importDb) { importDb.close(); } } try { fs.unlinkSync(req.file.path); } catch { apiLogger.warn("Failed to clean up uploaded file", { operation: "file_cleanup_warning", filePath: req.file.path, }); } res.json({ success: result.success, message: result.success ? "Incremental import completed successfully" : "Import failed", summary: result.summary, }); if (result.success) { apiLogger.success("SQLite data imported successfully", { operation: "sqlite_import_api_success", userId, summary: result.summary, }); } } catch (error) { if (req.file?.path && fs.existsSync(req.file.path)) { try { fs.unlinkSync(req.file.path); } catch { apiLogger.warn("Failed to clean up uploaded file after error", { operation: "file_cleanup_error", filePath: req.file.path, }); } } apiLogger.error("SQLite import failed", error, { operation: "sqlite_import_api_failed", userId: (req as AuthenticatedRequest).userId, }); res.status(500).json({ error: "Failed to import SQLite data", details: error instanceof Error ? error.message : "Unknown error", }); } }, ); /** * @openapi * /database/export/preview: * post: * summary: Preview user data export * description: Generates a preview of the user data export, including statistics about the data. * tags: * - Database * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * scope: * type: string * includeCredentials: * type: boolean * responses: * 200: * description: Export preview generated successfully. * 500: * description: Failed to generate export preview. */ app.post("/database/export/preview", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const { scope = "user_data", includeCredentials = true } = req.body; const exportData = await UserDataExport.exportUserData(userId, { format: "encrypted", scope, includeCredentials, }); const stats = UserDataExport.getExportStats(exportData); res.json({ preview: true, stats, estimatedSize: JSON.stringify(exportData).length, }); apiLogger.success("Export preview generated", { operation: "export_preview_api_success", userId, totalRecords: stats.totalRecords, }); } catch (error) { apiLogger.error("Export preview failed", error, { operation: "export_preview_api_failed", }); res.status(500).json({ error: "Failed to generate export preview", details: error instanceof Error ? error.message : "Unknown error", }); } }); /** * @openapi * /database/restore: * post: * summary: Restore database from backup * description: Restores the database from an encrypted backup file. * tags: * - Database * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * backupPath: * type: string * targetPath: * type: string * responses: * 200: * description: Database restored successfully. * 400: * description: Backup path is required or invalid encrypted backup file. * 500: * description: Database restore failed. */ app.post("/database/restore", requireAdmin, async (req, res) => { try { const { backupPath, targetPath } = req.body; if (!backupPath) { return res.status(400).json({ error: "Backup path is required" }); } if (!DatabaseFileEncryption.isEncryptedDatabaseFile(backupPath)) { return res.status(400).json({ error: "Invalid encrypted backup file" }); } const restoredPath = await DatabaseFileEncryption.restoreFromEncryptedBackup( backupPath, targetPath, ); res.json({ success: true, message: "Database restored successfully", restoredPath, }); } catch (error) { apiLogger.error("Database restore failed", error, { operation: "database_restore_api_failed", }); res.status(500).json({ error: "Database restore failed", details: error instanceof Error ? error.message : "Unknown error", }); } }); app.use("/users", userRoutes); app.use("/host", hostRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); app.use("/terminal", terminalRoutes); app.use("/guacamole", guacamoleRoutes); app.use("/network-topology", networkTopologyRoutes); app.use("/rbac", rbacRoutes); const frontendDistPaths = [ path.join(__dirname, "../../../dist"), path.join(__dirname, "../../dist"), path.join(process.cwd(), "dist"), ]; const frontendDist = frontendDistPaths.find((p) => fs.existsSync(path.join(p, "index.html")), ); if (frontendDist) { databaseLogger.info(`Serving frontend from: ${frontendDist}`, { operation: "static_files", }); app.use(express.static(frontendDist)); app.use((req, res, next) => { if (req.method === "GET" && req.accepts("html")) { res.sendFile(path.join(frontendDist, "index.html")); } else { next(); } }); } app.use( // eslint-disable-next-line @typescript-eslint/no-unused-vars ( err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction, ) => { apiLogger.error("Unhandled error in request", err, { operation: "error_handler", method: req.method, url: req.url, userAgent: req.get("User-Agent"), }); res.status(500).json({ error: "Internal Server Error" }); }, ); const HTTP_PORT = 30001; async function initializeSecurity() { try { const authManager = AuthManager.getInstance(); await authManager.initialize(); DataCrypto.initialize(); const isValid = true; if (!isValid) { throw new Error("Security system validation failed"); } } catch (error) { databaseLogger.error("Failed to initialize security system", error, { operation: "security_init_error", }); throw error; } } /** * @openapi * /database/migration/status: * get: * summary: Get database migration status * description: Returns the status of the database migration. * tags: * - Database * responses: * 200: * description: Migration status. * 500: * description: Failed to get migration status. */ app.get( "/database/migration/status", authenticateJWT, requireAdmin, async (req, res) => { try { const dataDir = process.env.DATA_DIR || "./db/data"; const migration = new DatabaseMigration(dataDir); const status = migration.checkMigrationStatus(); const dbPath = path.join(dataDir, "db.sqlite"); const encryptedDbPath = `${dbPath}.encrypted`; const files = fs.readdirSync(dataDir); const backupFiles = files.filter((f) => f.includes(".migration-backup-")); const migratedFiles = files.filter((f) => f.includes(".migrated-")); let unencryptedSize = 0; let encryptedSize = 0; if (status.hasUnencryptedDb) { try { unencryptedSize = fs.statSync(dbPath).size; } catch { // expected - file may not exist } } if (status.hasEncryptedDb) { try { encryptedSize = fs.statSync(encryptedDbPath).size; } catch { // expected - file may not exist } } res.json({ migrationStatus: status, files: { unencryptedDbSize: unencryptedSize, encryptedDbSize: encryptedSize, backupFiles: backupFiles.length, migratedFiles: migratedFiles.length, }, }); } catch (error) { apiLogger.error("Failed to get migration status", error, { operation: "migration_status_api_failed", }); res.status(500).json({ error: "Failed to get migration status", details: error instanceof Error ? error.message : "Unknown error", }); } }, ); /** * @openapi * /database/migration/history: * get: * summary: Get database migration history * description: Returns the history of database migrations. * tags: * - Database * responses: * 200: * description: Migration history. * 500: * description: Failed to get migration history. */ app.get( "/database/migration/history", authenticateJWT, requireAdmin, async (req, res) => { try { const dataDir = process.env.DATA_DIR || "./db/data"; const files = fs.readdirSync(dataDir); const backupFiles = files .filter((f) => f.includes(".migration-backup-")) .map((f) => { const filePath = path.join(dataDir, f); const stats = fs.statSync(filePath); return { name: f, size: stats.size, created: stats.birthtime, modified: stats.mtime, type: "backup", }; }) .sort((a, b) => b.modified.getTime() - a.modified.getTime()); const migratedFiles = files .filter((f) => f.includes(".migrated-")) .map((f) => { const filePath = path.join(dataDir, f); const stats = fs.statSync(filePath); return { name: f, size: stats.size, created: stats.birthtime, modified: stats.mtime, type: "migrated", }; }) .sort((a, b) => b.modified.getTime() - a.modified.getTime()); res.json({ files: [...backupFiles, ...migratedFiles], summary: { totalBackups: backupFiles.length, totalMigrated: migratedFiles.length, oldestBackup: backupFiles.length > 0 ? backupFiles[backupFiles.length - 1].created : null, newestBackup: backupFiles.length > 0 ? backupFiles[0].created : null, }, }); } catch (error) { apiLogger.error("Failed to get migration history", error, { operation: "migration_history_api_failed", }); res.status(500).json({ error: "Failed to get migration history", details: error instanceof Error ? error.message : "Unknown error", }); } }, ); app.listen(HTTP_PORT, async () => { const uploadsDir = path.join(process.cwd(), "uploads"); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } await initializeSecurity(); }); const sslConfig = AutoSSLSetup.getSSLConfig(); if (sslConfig.enabled) { databaseLogger.info(`SSL is enabled`, { operation: "ssl_info", nginx_https_port: sslConfig.port, backend_http_port: HTTP_PORT, }); } ================================================ FILE: src/backend/database/db/index.ts ================================================ import { drizzle } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "./schema.js"; import fs from "fs"; import path from "path"; import { databaseLogger } from "../../utils/logger.js"; 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"; const dataDir = process.env.DATA_DIR || "./db/data"; const dbDir = path.resolve(dataDir); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false"; const dbPath = path.join(dataDir, "db.sqlite"); const encryptedDbPath = `${dbPath}.encrypted`; const actualDbPath = ":memory:"; let memoryDatabase: Database.Database; let isNewDatabase = false; let sqlite: Database.Database; async function initializeDatabaseAsync(): Promise { const systemCrypto = SystemCrypto.getInstance(); await systemCrypto.getDatabaseKey(); if (enableFileEncryption) { try { if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) { const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath); memoryDatabase = new Database(decryptedBuffer); try { memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; } catch { // expected - sessions table may not exist yet } } else { const migration = new DatabaseMigration(dataDir); const migrationStatus = migration.checkMigrationStatus(); if (migrationStatus.needsMigration) { const migrationResult = await migration.migrateDatabase(); if (migrationResult.success) { migration.cleanupOldBackups(); if ( DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath) ) { const decryptedBuffer = await DatabaseFileEncryption.decryptDatabaseToBuffer( encryptedDbPath, ); memoryDatabase = new Database(decryptedBuffer); isNewDatabase = false; } else { throw new Error( "Migration completed but encrypted database file not found", ); } } else { databaseLogger.error("Automatic database migration failed", null, { operation: "auto_migration_failed", error: migrationResult.error, migratedTables: migrationResult.migratedTables, migratedRows: migrationResult.migratedRows, duration: migrationResult.duration, backupPath: migrationResult.backupPath, }); throw new Error( `Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`, ); } } else { memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } } catch (error) { databaseLogger.error("Failed to initialize memory database", error, { operation: "db_memory_init_failed", errorMessage: error instanceof Error ? error.message : "Unknown error", errorStack: error instanceof Error ? error.stack : undefined, encryptedDbExists: DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), databaseKeyAvailable: !!process.env.DATABASE_KEY, databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); try { const diagnosticInfo = DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); databaseLogger.error( "Database encryption diagnostic completed - check logs above for details", null, { operation: "db_encryption_diagnostic_completed", filesConsistent: diagnosticInfo.validation.filesConsistent, sizeMismatch: diagnosticInfo.validation.sizeMismatch, }, ); } catch (diagError) { databaseLogger.warn("Failed to generate diagnostic information", { operation: "db_diagnostic_failed", error: diagError instanceof Error ? diagError.message : "Unknown error", }); } throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, ); } } else { memoryDatabase = new Database(":memory:"); isNewDatabase = true; } } async function initializeCompleteDatabase(): Promise { await initializeDatabaseAsync(); databaseLogger.info(`Initializing SQLite database`, { operation: "db_init", path: actualDbPath, encrypted: enableFileEncryption && DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath), inMemory: true, isNewDatabase, }); sqlite = memoryDatabase; sqlite.exec("PRAGMA foreign_keys = ON"); db = drizzle(sqlite, { schema }); sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, is_oidc INTEGER NOT NULL DEFAULT 0, oidc_identifier TEXT, client_id TEXT, client_secret TEXT, issuer_url TEXT, authorization_url TEXT, token_url TEXT, identifier_path TEXT, name_path TEXT, scopes TEXT DEFAULT 'openid email profile', totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT 0, totp_backup_codes TEXT ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, jwt_token TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS trusted_devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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, name TEXT, ip TEXT NOT NULL, port INTEGER NOT NULL, username TEXT NOT NULL, folder TEXT, tags TEXT, pin INTEGER NOT NULL DEFAULT 0, auth_type TEXT NOT NULL, password TEXT, key TEXT, key_password TEXT, key_type TEXT, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, enable_file_manager INTEGER NOT NULL DEFAULT 1, enable_docker INTEGER NOT NULL DEFAULT 0, default_path TEXT, autostart_password TEXT, autostart_key TEXT, autostart_key_password TEXT, force_keyboard_interactive TEXT, stats_config TEXT, docker_config TEXT, terminal_config TEXT, notes TEXT, use_socks5 INTEGER, socks5_host TEXT, socks5_port INTEGER, socks5_username TEXT, socks5_password 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 ); CREATE TABLE IF NOT EXISTS file_manager_recent ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_pinned ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, name TEXT NOT NULL, path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dismissed_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, alert_id TEXT NOT NULL, dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, folder TEXT, tags TEXT, auth_type TEXT NOT NULL, username TEXT, password TEXT, key TEXT, key_password TEXT, key_type TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used 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 ); CREATE TABLE IF NOT EXISTS ssh_credential_usage ( id INTEGER PRIMARY KEY AUTOINCREMENT, credential_id INTEGER NOT NULL, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS snippets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL, 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 ); CREATE TABLE IF NOT EXISTS ssh_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, icon 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 ); CREATE TABLE IF NOT EXISTS recent_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, type TEXT NOT NULL, host_id INTEGER NOT NULL, host_name TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS command_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, command TEXT NOT NULL, executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT, role_id INTEGER, granted_by TEXT NOT NULL, permission_level TEXT NOT NULL DEFAULT 'use', expires_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, access_count INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, description TEXT, is_system INTEGER NOT NULL DEFAULT 0, permissions TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, role_id INTEGER NOT NULL, granted_by TEXT, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, role_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, username TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, resource_name TEXT, details TEXT, ip_address TEXT, user_agent TEXT, success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS session_recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, access_id INTEGER, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at TEXT, duration INTEGER, commands TEXT, dangerous_actions TEXT, recording_path TEXT, terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); `); try { sqlite.prepare("DELETE FROM sessions").run(); } catch (e) { databaseLogger.warn("Could not clear expired sessions on startup", { operation: "db_init_session_cleanup_failed", error: e, }); } migrateSchema(); try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize default settings", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize allow_password_login setting", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize guac_enabled setting", { operation: "db_init", error: e, }); } try { const row = sqlite .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get(); if (!row) { sqlite .prepare( "INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')", ) .run(); } } catch (e) { databaseLogger.warn("Could not initialize guac_url setting", { operation: "db_init", error: e, }); } } const addColumnIfNotExists = ( table: string, column: string, definition: string, ) => { try { sqlite .prepare( `SELECT "${column}" FROM ${table} LIMIT 1`, ) .get(); } catch { try { sqlite.exec(`ALTER TABLE ${table} ADD COLUMN "${column}" ${definition};`); } catch (alterError) { databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: "schema_migration", table, column, error: alterError, }); } } }; const migrateSchema = () => { addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "oidc_identifier", "TEXT"); addColumnIfNotExists("users", "client_id", "TEXT"); addColumnIfNotExists("users", "client_secret", "TEXT"); addColumnIfNotExists("users", "issuer_url", "TEXT"); addColumnIfNotExists("users", "authorization_url", "TEXT"); addColumnIfNotExists("users", "token_url", "TEXT"); addColumnIfNotExists("users", "identifier_path", "TEXT"); addColumnIfNotExists("users", "name_path", "TEXT"); addColumnIfNotExists("users", "scopes", "TEXT"); addColumnIfNotExists("users", "totp_secret", "TEXT"); addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("users", "totp_backup_codes", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "tags", "TEXT"); addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists( "ssh_data", "auth_type", 'TEXT NOT NULL DEFAULT "password"', ); addColumnIfNotExists("ssh_data", "password", "TEXT"); addColumnIfNotExists("ssh_data", "key", "TEXT"); addColumnIfNotExists("ssh_data", "key_password", "TEXT"); addColumnIfNotExists("ssh_data", "key_type", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_terminal", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists( "ssh_data", "enable_tunnel", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT"); addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_file_manager", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "default_path", "TEXT"); addColumnIfNotExists( "ssh_data", "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", ); addColumnIfNotExists( "ssh_data", "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP", ); addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists( "ssh_data", "credential_id", "INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL", ); addColumnIfNotExists( "ssh_data", "override_credential_username", "INTEGER", ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key", "TEXT"); addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_data", "quick_actions", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_docker", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"'); addColumnIfNotExists("ssh_data", "domain", "TEXT"); addColumnIfNotExists("ssh_data", "security", "TEXT"); addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0"); addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT"); addColumnIfNotExists("ssh_data", "notes", "TEXT"); addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER"); addColumnIfNotExists("ssh_data", "socks5_host", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER"); addColumnIfNotExists("ssh_data", "socks5_username", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_password", "TEXT"); addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_fingerprint", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_type", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_algorithm", "TEXT DEFAULT 'sha256'"); addColumnIfNotExists("ssh_data", "host_key_first_seen", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT"); addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0"); addColumnIfNotExists( "ssh_data", "show_terminal_in_sidebar", "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists( "ssh_data", "show_file_manager_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_tunnel_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_docker_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists( "ssh_data", "show_server_stats_in_sidebar", "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); try { const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{ cid: number; name: string; type: string; notnull: number; dflt_value: string | null; pk: number; }>; const usernameCol = tableInfo.find((col) => col.name === "username"); if (usernameCol && usernameCol.notnull === 1) { const tempTableName = "ssh_credentials_temp_migration"; const allColumns = tableInfo.map((col) => col.name).join(", "); sqlite.exec(`PRAGMA foreign_keys = OFF`); sqlite.exec(` CREATE TABLE ${tempTableName} ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT, folder TEXT, tags TEXT, auth_type TEXT NOT NULL, username TEXT, password TEXT, key TEXT, key_password TEXT, key_type TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, private_key TEXT, public_key TEXT, detected_key_type TEXT, system_password TEXT, system_key TEXT, system_key_password TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); INSERT INTO ${tempTableName} SELECT ${allColumns} FROM ssh_credentials; DROP TABLE ssh_credentials; ALTER TABLE ${tempTableName} RENAME TO ssh_credentials; `); sqlite.exec(`PRAGMA foreign_keys = ON`); databaseLogger.info("Successfully migrated ssh_credentials table to remove username NOT NULL constraint", { operation: "schema_migration_username_nullable", }); } } catch (migrationError) { databaseLogger.warn("Failed to migrate ssh_credentials username column", { operation: "schema_migration", error: migrationError, }); } addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("snippets", "folder", "TEXT"); addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0"); try { sqlite .prepare("SELECT id FROM snippet_folders LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS snippet_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, icon 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 snippet_folders table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM sessions LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, jwt_token TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ); `); } catch (createError) { databaseLogger.warn("Failed to create sessions table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM trusted_devices LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS trusted_devices ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_fingerprint TEXT NOT NULL, device_type TEXT NOT NULL, device_info TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create trusted_devices table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM network_topology LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS network_topology ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, topology 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 network_topology table", { operation: "schema_migration", error: createError, }); } } try { sqlite .prepare("SELECT id FROM dashboard_preferences LIMIT 1") .get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS dashboard_preferences ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, layout TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create dashboard_preferences table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM host_access LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT, role_id INTEGER, granted_by TEXT NOT NULL, permission_level TEXT NOT NULL DEFAULT 'use', expires_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_accessed_at TEXT, access_count INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create host_access table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get(); } catch { try { sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE"); } catch (alterError) { databaseLogger.warn("Failed to add role_id column", { operation: "schema_migration", error: alterError, }); } } try { sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get(); } catch { try { sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT"); } catch (alterError) { databaseLogger.warn("Failed to add sudo_password column", { operation: "schema_migration", error: alterError, }); } } const sshDataMigrations: Array<{ column: string; sql: string }> = [ { column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" }, { column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" }, { column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" }, { column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" }, { column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" }, { column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" }, { column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" }, { column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" }, { column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" }, { column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" }, { column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" }, { column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" }, { column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" }, { column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" }, { column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" }, { column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" }, { column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" }, { column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" }, ]; for (const migration of sshDataMigrations) { try { sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get(); } catch { try { sqlite.exec(migration.sql); } catch (alterError) { databaseLogger.warn(`Failed to add ${migration.column} column`, { operation: "schema_migration", error: alterError, }); } } } try { sqlite.prepare("SELECT id FROM roles LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, description TEXT, is_system INTEGER NOT NULL DEFAULT 0, permissions TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `); } catch (createError) { databaseLogger.warn("Failed to create roles table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS user_roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, role_id INTEGER NOT NULL, granted_by TEXT, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, role_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL ); `); } catch (createError) { databaseLogger.warn("Failed to create user_roles table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, username TEXT NOT NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, resource_name TEXT, details TEXT, ip_address TEXT, user_agent TEXT, success INTEGER NOT NULL, error_message TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create audit_logs table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS session_recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, user_id TEXT NOT NULL, access_id INTEGER, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at TEXT, duration INTEGER, commands TEXT, dangerous_actions TEXT, recording_path TEXT, terminated_by_owner INTEGER DEFAULT 0, termination_reason TEXT, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); `); } catch (createError) { databaseLogger.warn("Failed to create session_recordings table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS shared_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_access_id INTEGER NOT NULL, original_credential_id INTEGER NOT NULL, target_user_id TEXT NOT NULL, encrypted_username TEXT NOT NULL, encrypted_auth_type TEXT NOT NULL, encrypted_password TEXT, encrypted_key TEXT, encrypted_key_password TEXT, encrypted_key_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, needs_re_encryption INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE, FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create shared_credentials table", { operation: "schema_migration", error: createError, }); } } try { sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get(); } catch { try { sqlite.exec(` CREATE TABLE IF NOT EXISTS opkssh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, host_id INTEGER NOT NULL, ssh_cert TEXT NOT NULL, private_key TEXT NOT NULL, email TEXT, sub TEXT, issuer TEXT, audience TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_used TEXT, UNIQUE(user_id, host_id), FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); `); } catch (createError) { databaseLogger.warn("Failed to create opkssh_tokens table", { operation: "schema_migration", error: createError, }); } } try { const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>; try { const validSystemRoles = ['admin', 'user']; const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member']; const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?"); for (const roleName of unwantedRoleNames) { deleteByName.run(roleName); } const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1"); for (const role of existingRoles) { if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) { deleteOldSystemRole.run(role.name); } } } catch (cleanupError) { databaseLogger.warn("Failed to clean up old system roles", { operation: "schema_migration", error: cleanupError, }); } const systemRoles = [ { name: "admin", displayName: "rbac.roles.admin", description: "Administrator with full access", permissions: null, }, { name: "user", displayName: "rbac.roles.user", description: "Regular user", permissions: null, }, ]; for (const role of systemRoles) { const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name); if (!existingRole) { try { sqlite.prepare(` INSERT INTO roles (name, display_name, description, is_system, permissions) VALUES (?, ?, ?, 1, ?) `).run(role.name, role.displayName, role.description, role.permissions); } catch (insertError) { databaseLogger.warn(`Failed to create system role: ${role.name}`, { operation: "schema_migration", error: insertError, }); } } } try { const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[]; const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[]; const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined; const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined; if (adminRole) { const insertUserRole = sqlite.prepare(` INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) VALUES (?, ?, CURRENT_TIMESTAMP) `); for (const admin of adminUsers) { try { insertUserRole.run(admin.id, adminRole.id); } catch { // Ignore duplicate errors } } } if (userRole) { const insertUserRole = sqlite.prepare(` INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at) VALUES (?, ?, CURRENT_TIMESTAMP) `); for (const user of normalUsers) { try { insertUserRole.run(user.id, userRole.id); } catch { // Ignore duplicate errors } } } } catch (migrationError) { databaseLogger.warn("Failed to migrate existing users to roles", { operation: "schema_migration", error: migrationError, }); } } catch (seedError) { databaseLogger.warn("Failed to seed system roles", { operation: "schema_migration", error: seedError, }); } databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); }; async function saveMemoryDatabaseToFile() { if (!memoryDatabase) return; try { const buffer = memoryDatabase.serialize(); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } try { memoryDatabase .prepare("SELECT COUNT(*) as count FROM sessions") .get() as { count: number }; } catch { // expected - sessions table may not exist yet } if (enableFileEncryption) { await DatabaseFileEncryption.encryptDatabaseFromBuffer( buffer, encryptedDbPath, ); } else { fs.writeFileSync(dbPath, buffer); } DatabaseSaveTrigger.markClean(); } catch (error) { databaseLogger.error("Failed to save in-memory database", error, { operation: "memory_db_save_failed", enableFileEncryption, }); } } async function handlePostInitFileEncryption() { if (!enableFileEncryption) return; try { if (memoryDatabase) { await saveMemoryDatabaseToFile(); setInterval(() => { if (DatabaseSaveTrigger.isDirty) { saveMemoryDatabaseToFile(); } }, 5 * 60 * 1000); DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile); } try { const migration = new DatabaseMigration(dataDir); migration.cleanupOldBackups(); } catch (cleanupError) { databaseLogger.warn("Failed to cleanup old migration files", { operation: "migration_cleanup_startup_failed", error: cleanupError instanceof Error ? cleanupError.message : "Unknown error", }); } } catch (error) { databaseLogger.error( "Failed to handle database file encryption setup", error, { operation: "db_encrypt_setup_failed", }, ); } } async function initializeDatabase(): Promise { await initializeCompleteDatabase(); await handlePostInitFileEncryption(); } export { initializeDatabase }; async function cleanupDatabase() { if (memoryDatabase) { try { await saveMemoryDatabaseToFile(); } catch (error) { databaseLogger.error( "Failed to save in-memory database before shutdown", error, { operation: "shutdown_save_failed", }, ); } } try { if (sqlite) { sqlite.close(); } } catch (error) { databaseLogger.warn("Error closing database connection", { operation: "db_close_error", error: error instanceof Error ? error.message : "Unknown error", }); } try { const tempDir = path.join(dataDir, ".temp"); if (fs.existsSync(tempDir)) { const files = fs.readdirSync(tempDir); for (const file of files) { try { fs.unlinkSync(path.join(tempDir, file)); } catch { // expected - file cleanup best effort } } try { fs.rmdirSync(tempDir); } catch { // expected - dir cleanup best effort } } } catch { // expected - temp dir cleanup best effort } } process.on("exit", () => { if (sqlite) { try { sqlite.close(); } catch { // expected - database may already be closed } } }); process.on("SIGINT", async () => { databaseLogger.info("Received SIGINT, cleaning up...", { operation: "shutdown", }); await cleanupDatabase(); process.exit(0); }); process.on("SIGTERM", async () => { databaseLogger.info("Received SIGTERM, cleaning up...", { operation: "shutdown", }); await cleanupDatabase(); process.exit(0); }); let db: ReturnType>; export function getDb(): ReturnType> { if (!db) { throw new Error( "Database not initialized. Ensure initializeDatabase() is called before accessing db.", ); } return db; } export function getSqlite(): Database.Database { if (!sqlite) { throw new Error( "SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.", ); } return sqlite; } export { db }; export { DatabaseFileEncryption }; export const databasePaths = { main: actualDbPath, encrypted: encryptedDbPath, directory: dbDir, inMemory: true, }; export { saveMemoryDatabaseToFile }; export { DatabaseSaveTrigger }; ================================================ FILE: src/backend/database/db/schema.ts ================================================ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; export const users = sqliteTable("users", { id: text("id").primaryKey(), username: text("username").notNull(), passwordHash: text("password_hash").notNull(), isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false), isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false), oidcIdentifier: text("oidc_identifier"), clientId: text("client_id"), clientSecret: text("client_secret"), issuerUrl: text("issuer_url"), authorizationUrl: text("authorization_url"), tokenUrl: text("token_url"), identifierPath: text("identifier_path"), namePath: text("name_path"), scopes: text().default("openid email profile"), totpSecret: text("totp_secret"), totpEnabled: integer("totp_enabled", { mode: "boolean" }) .notNull() .default(false), totpBackupCodes: text("totp_backup_codes"), }); export const settings = sqliteTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const sessions = sqliteTable("sessions", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), jwtToken: text("jwt_token").notNull(), deviceType: text("device_type").notNull(), deviceInfo: text("device_info").notNull(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), expiresAt: text("expires_at").notNull(), lastActiveAt: text("last_active_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const trustedDevices = sqliteTable("trusted_devices", { id: text("id").primaryKey(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), deviceFingerprint: text("device_fingerprint").notNull(), deviceType: text("device_type").notNull(), deviceInfo: text("device_info").notNull(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), expiresAt: text("expires_at").notNull(), lastUsedAt: text("last_used_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const hosts = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), connectionType: text("connection_type").notNull().default("ssh"), name: text("name"), ip: text("ip").notNull(), port: integer("port").notNull(), username: text("username").notNull(), folder: text("folder"), tags: text("tags"), pin: integer("pin", { mode: "boolean" }).notNull().default(false), authType: text("auth_type").notNull(), forceKeyboardInteractive: text("force_keyboard_interactive"), password: text("password"), key: text("key", { length: 8192 }), keyPassword: text("key_password"), keyType: text("key_type"), sudoPassword: text("sudo_password"), autostartPassword: text("autostart_password"), autostartKey: text("autostart_key", { length: 8192 }), autostartKeyPassword: text("autostart_key_password"), credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), overrideCredentialUsername: integer("override_credential_username", { mode: "boolean", }), enableTerminal: integer("enable_terminal", { mode: "boolean" }) .notNull() .default(true), enableTunnel: integer("enable_tunnel", { mode: "boolean" }) .notNull() .default(true), tunnelConnections: text("tunnel_connections"), jumpHosts: text("jump_hosts"), enableFileManager: integer("enable_file_manager", { mode: "boolean" }) .notNull() .default(true), enableDocker: integer("enable_docker", { mode: "boolean" }) .notNull() .default(false), showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" }) .notNull() .default(true), showFileManagerInSidebar: integer("show_file_manager_in_sidebar", { mode: "boolean" }) .notNull() .default(false), showTunnelInSidebar: integer("show_tunnel_in_sidebar", { mode: "boolean" }) .notNull() .default(false), showDockerInSidebar: integer("show_docker_in_sidebar", { mode: "boolean" }) .notNull() .default(false), showServerStatsInSidebar: integer("show_server_stats_in_sidebar", { mode: "boolean" }) .notNull() .default(false), defaultPath: text("default_path"), statsConfig: text("stats_config"), dockerConfig: text("docker_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), domain: text("domain"), security: text("security"), ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false), guacamoleConfig: text("guacamole_config"), useSocks5: integer("use_socks5", { mode: "boolean" }), socks5Host: text("socks5_host"), socks5Port: integer("socks5_port"), socks5Username: text("socks5_username"), socks5Password: text("socks5_password"), socks5ProxyChain: text("socks5_proxy_chain"), hostKeyFingerprint: text("host_key_fingerprint"), hostKeyType: text("host_key_type"), hostKeyAlgorithm: text("host_key_algorithm").default("sha256"), hostKeyFirstSeen: text("host_key_first_seen"), hostKeyLastVerified: text("host_key_last_verified"), hostKeyChangedCount: integer("host_key_changed_count").default(0), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const fileManagerRecent = sqliteTable("file_manager_recent", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), lastOpened: text("last_opened") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const fileManagerPinned = sqliteTable("file_manager_pinned", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), pinnedAt: text("pinned_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const dismissedAlerts = sqliteTable("dismissed_alerts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), alertId: text("alert_id").notNull(), dismissedAt: text("dismissed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const sshCredentials = sqliteTable("ssh_credentials", { 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"), authType: text("auth_type").notNull(), username: text("username"), password: text("password"), key: text("key", { length: 16384 }), privateKey: text("private_key", { length: 16384 }), publicKey: text("public_key", { length: 4096 }), keyPassword: text("key_password"), keyType: text("key_type"), detectedKeyType: text("detected_key_type"), systemPassword: text("system_password"), systemKey: text("system_key", { length: 16384 }), systemKeyPassword: text("system_key_password"), usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { id: integer("id").primaryKey({ autoIncrement: true }), credentialId: integer("credential_id") .notNull() .references(() => sshCredentials.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), usedAt: text("used_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const snippets = sqliteTable("snippets", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), content: text("content").notNull(), description: text("description"), folder: text("folder"), order: integer("order").notNull().default(0), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const snippetFolders = sqliteTable("snippet_folders", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), color: text("color"), icon: text("icon"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const sshFolders = sqliteTable("ssh_folders", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), color: text("color"), icon: text("icon"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), type: text("type").notNull(), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), hostName: text("host_name"), timestamp: text("timestamp") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const commandHistory = sqliteTable("command_history", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), command: text("command").notNull(), executedAt: text("executed_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const networkTopology = sqliteTable("network_topology", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), topology: text("topology"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const dashboardPreferences = sqliteTable("dashboard_preferences", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .unique() .references(() => users.id, { onDelete: "cascade" }), layout: text("layout").notNull(), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const hostAccess = sqliteTable("host_access", { id: integer("id").primaryKey({ autoIncrement: true }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), userId: text("user_id") .references(() => users.id, { onDelete: "cascade" }), roleId: integer("role_id") .references(() => roles.id, { onDelete: "cascade" }), grantedBy: text("granted_by") .notNull() .references(() => users.id, { onDelete: "cascade" }), permissionLevel: text("permission_level") .notNull() .default("view"), expiresAt: text("expires_at"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), lastAccessedAt: text("last_accessed_at"), accessCount: integer("access_count").notNull().default(0), }); export const sharedCredentials = sqliteTable("shared_credentials", { id: integer("id").primaryKey({ autoIncrement: true }), hostAccessId: integer("host_access_id") .notNull() .references(() => hostAccess.id, { onDelete: "cascade" }), originalCredentialId: integer("original_credential_id") .notNull() .references(() => sshCredentials.id, { onDelete: "cascade" }), targetUserId: text("target_user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), encryptedUsername: text("encrypted_username").notNull(), encryptedAuthType: text("encrypted_auth_type").notNull(), encryptedPassword: text("encrypted_password"), encryptedKey: text("encrypted_key", { length: 16384 }), encryptedKeyPassword: text("encrypted_key_password"), encryptedKeyType: text("encrypted_key_type"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), needsReEncryption: integer("needs_re_encryption", { mode: "boolean" }) .notNull() .default(false), }); export const roles = sqliteTable("roles", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), displayName: text("display_name").notNull(), description: text("description"), isSystem: integer("is_system", { mode: "boolean" }) .notNull() .default(false), permissions: text("permissions"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const userRoles = sqliteTable("user_roles", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), roleId: integer("role_id") .notNull() .references(() => roles.id, { onDelete: "cascade" }), grantedBy: text("granted_by").references(() => users.id, { onDelete: "set null", }), grantedAt: text("granted_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const auditLogs = sqliteTable("audit_logs", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), username: text("username").notNull(), action: text("action").notNull(), resourceType: text("resource_type").notNull(), resourceId: text("resource_id"), resourceName: text("resource_name"), details: text("details"), ipAddress: text("ip_address"), userAgent: text("user_agent"), success: integer("success", { mode: "boolean" }).notNull(), errorMessage: text("error_message"), timestamp: text("timestamp") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); export const sessionRecordings = sqliteTable("session_recordings", { id: integer("id").primaryKey({ autoIncrement: true }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), accessId: integer("access_id").references(() => hostAccess.id, { onDelete: "set null", }), startedAt: text("started_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), endedAt: text("ended_at"), duration: integer("duration"), commands: text("commands"), dangerousActions: text("dangerous_actions"), recordingPath: text("recording_path"), terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" }) .default(false), terminationReason: text("termination_reason"), }); export const opksshTokens = sqliteTable("opkssh_tokens", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() .references(() => hosts.id, { onDelete: "cascade" }), sshCert: text("ssh_cert", { length: 8192 }).notNull(), privateKey: text("private_key", { length: 8192 }).notNull(), email: text("email"), sub: text("sub"), issuer: text("issuer"), audience: text("audience"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), expiresAt: text("expires_at").notNull(), lastUsed: text("last_used"), }); ================================================ FILE: src/backend/database/routes/alerts.ts ================================================ import type { AuthenticatedRequest, CacheEntry, TermixAlert, } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { dismissedAlerts } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; import fetch from "node-fetch"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { getProxyAgent } from "../../utils/proxy-agent.js"; class AlertCache { private cache: Map = new Map(); private readonly CACHE_DURATION = 5 * 60 * 1000; set(key: string, data: T): void { const now = Date.now(); this.cache.set(key, { data, timestamp: now, expiresAt: now + this.CACHE_DURATION, }); } get(key: string): T | null { const entry = this.cache.get(key); if (!entry) { return null; } if (Date.now() > entry.expiresAt) { this.cache.delete(key); return null; } return entry.data as T; } } const alertCache = new AlertCache(); const GITHUB_RAW_BASE = "https://raw.githubusercontent.com"; const REPO_OWNER = "Termix-SSH"; const REPO_NAME = "Docs"; const ALERTS_FILE = "main/termix-alerts.json"; async function fetchAlertsFromGitHub(): Promise { const cacheKey = "termix_alerts"; const cachedData = alertCache.get(cacheKey); if (cachedData) { return cachedData; } try { const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; const response = await fetch(url, { headers: { Accept: "application/json", "User-Agent": "TermixAlertChecker/1.0", }, agent: getProxyAgent(url), }); if (!response.ok) { authLogger.warn("GitHub API returned error status", { operation: "alerts_fetch", status: response.status, statusText: response.statusText, }); throw new Error( `GitHub raw content error: ${response.status} ${response.statusText}`, ); } const alerts: TermixAlert[] = (await response.json()) as TermixAlert[]; const now = new Date(); const validAlerts = alerts.filter((alert) => { const expiryDate = new Date(alert.expiresAt); const isValid = expiryDate > now; return isValid; }); alertCache.set(cacheKey, validAlerts); return validAlerts; } catch (error) { authLogger.error("Failed to fetch alerts from GitHub", { operation: "alerts_fetch", error: error instanceof Error ? error.message : "Unknown error", }); return []; } } const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); /** * @openapi * /alerts: * get: * summary: Get active alerts * description: Fetches active alerts for the authenticated user, excluding those that have been dismissed. * tags: * - Alerts * responses: * 200: * description: A list of active alerts. * 500: * description: Failed to fetch alerts. */ router.get("/", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const allAlerts = await fetchAlertsFromGitHub(); const dismissedAlertRecords = await db .select({ alertId: dismissedAlerts.alertId }) .from(dismissedAlerts) .where(eq(dismissedAlerts.userId, userId)); const dismissedAlertIds = new Set( dismissedAlertRecords.map((record) => record.alertId), ); const activeAlertsForUser = allAlerts.filter( (alert) => !dismissedAlertIds.has(alert.id), ); res.json({ alerts: activeAlertsForUser, cached: alertCache.get("termix_alerts") !== null, total_count: activeAlertsForUser.length, }); } catch (error) { authLogger.error("Failed to get user alerts", error); res.status(500).json({ error: "Failed to fetch alerts" }); } }); /** * @openapi * /alerts/dismiss: * post: * summary: Dismiss an alert * description: Marks an alert as dismissed for the authenticated user. * tags: * - Alerts * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * alertId: * type: string * responses: * 200: * description: Alert dismissed successfully. * 400: * description: Alert ID is required. * 409: * description: Alert already dismissed. * 500: * description: Failed to dismiss alert. */ router.post("/dismiss", authenticateJWT, async (req, res) => { try { const { alertId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!alertId) { authLogger.warn("Missing alertId in dismiss request", { userId }); return res.status(400).json({ error: "Alert ID is required" }); } const existingDismissal = await db .select() .from(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.alertId, alertId), ), ); if (existingDismissal.length > 0) { authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`); return res.status(409).json({ error: "Alert already dismissed" }); } await db.insert(dismissedAlerts).values({ userId, alertId, }); res.json({ message: "Alert dismissed successfully" }); } catch (error) { authLogger.error("Failed to dismiss alert", error); res.status(500).json({ error: "Failed to dismiss alert" }); } }); /** * @openapi * /alerts/dismissed: * get: * summary: Get dismissed alerts * description: Fetches a list of alerts that have been dismissed by the authenticated user. * tags: * - Alerts * responses: * 200: * description: A list of dismissed alerts. * 500: * description: Failed to fetch dismissed alerts. */ router.get("/dismissed", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const dismissedAlertRecords = await db .select({ alertId: dismissedAlerts.alertId, dismissedAt: dismissedAlerts.dismissedAt, }) .from(dismissedAlerts) .where(eq(dismissedAlerts.userId, userId)); res.json({ dismissed_alerts: dismissedAlertRecords, total_count: dismissedAlertRecords.length, }); } catch (error) { authLogger.error("Failed to get dismissed alerts", error); res.status(500).json({ error: "Failed to fetch dismissed alerts" }); } }); /** * @openapi * /alerts/dismiss: * delete: * summary: Undismiss an alert * description: Removes an alert from the dismissed list for the authenticated user. * tags: * - Alerts * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * alertId: * type: string * responses: * 200: * description: Alert undismissed successfully. * 400: * description: Alert ID is required. * 404: * description: Dismissed alert not found. * 500: * description: Failed to undismiss alert. */ router.delete("/dismiss", authenticateJWT, async (req, res) => { try { const { alertId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!alertId) { return res.status(400).json({ error: "Alert ID is required" }); } const result = await db .delete(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, userId), eq(dismissedAlerts.alertId, alertId), ), ); if (result.changes === 0) { return res.status(404).json({ error: "Dismissed alert not found" }); } res.json({ message: "Alert undismissed successfully" }); } catch (error) { authLogger.error("Failed to undismiss alert", error); res.status(500).json({ error: "Failed to undismiss alert" }); } }); export default router; ================================================ FILE: src/backend/database/routes/credentials.ts ================================================ import type { AuthenticatedRequest, CredentialBackend, } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { sshCredentials, sshCredentialUsage, hosts, hostAccess, } from "../db/schema.js"; import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { parseSSHKey, parsePublicKey, validateKeyPair, } from "../../utils/ssh-key-utils.js"; import crypto from "crypto"; import ssh2Pkg from "ssh2"; const { utils: ssh2Utils, Client } = ssh2Pkg; function generateSSHKeyPair( keyType: string, keySize?: number, passphrase?: string, ): { success: boolean; privateKey?: string; publicKey?: string; error?: string; } { try { let ssh2Type = keyType; const options: { bits?: number; passphrase?: string; cipher?: string; } = {}; if (keyType === "ssh-rsa") { ssh2Type = "rsa"; options.bits = keySize || 2048; } else if (keyType === "ssh-ed25519") { ssh2Type = "ed25519"; } else if (keyType === "ecdsa-sha2-nistp256") { ssh2Type = "ecdsa"; options.bits = 256; } if (passphrase && passphrase.trim()) { options.passphrase = passphrase; options.cipher = "aes128-cbc"; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); return { success: true, privateKey: keyPair.private, publicKey: keyPair.public, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "SSH key generation failed", }; } } const router = express.Router(); function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); /** * @openapi * /credentials: * post: * summary: Create a new credential * description: Creates a new SSH credential for the authenticated user. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * description: * type: string * folder: * type: string * tags: * type: array * items: * type: string * authType: * type: string * enum: [password, key] * username: * type: string * password: * type: string * key: * type: string * keyPassword: * type: string * keyType: * type: string * responses: * 201: * description: Credential created successfully. * 400: * description: Invalid request body. * 500: * description: Failed to create credential. */ router.post( "/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { name, description, folder, tags, authType, username, password, key, keyPassword, keyType, } = req.body; if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { authLogger.warn("Invalid credential creation data validation failed", { operation: "credential_create", userId, hasName: !!name, }); return res.status(400).json({ error: "Name is required" }); } if (!["password", "key"].includes(authType)) { authLogger.warn("Invalid auth type provided", { operation: "credential_create", userId, name, authType, }); return res .status(400) .json({ error: 'Auth type must be "password" or "key"' }); } try { if (authType === "password" && !password) { authLogger.warn("Password required for password authentication", { operation: "credential_create", userId, name, authType, }); return res .status(400) .json({ error: "Password is required for password authentication" }); } if (authType === "key" && !key) { authLogger.warn("SSH key required for key authentication", { operation: "credential_create", userId, name, authType, }); return res .status(400) .json({ error: "SSH key is required for key authentication" }); } const plainPassword = authType === "password" && password ? password : null; const plainKey = authType === "key" && key ? key : null; const plainKeyPassword = authType === "key" && keyPassword ? keyPassword : null; let keyInfo = null; if (authType === "key" && plainKey) { keyInfo = parseSSHKey(plainKey, plainKeyPassword); if (!keyInfo.success) { authLogger.warn("SSH key parsing failed", { operation: "credential_create", userId, name, error: keyInfo.error, }); return res.status(400).json({ error: `Invalid SSH key: ${keyInfo.error}`, }); } } const credentialData = { userId, name: name.trim(), description: description?.trim() || null, folder: folder?.trim() || null, tags: Array.isArray(tags) ? tags.join(",") : tags || "", authType, username: username?.trim() || null, password: plainPassword, key: plainKey, privateKey: keyInfo?.privateKey || plainKey, publicKey: keyInfo?.publicKey || null, keyPassword: plainKeyPassword, keyType: keyType || null, detectedKeyType: keyInfo?.keyType || null, usageCount: 0, lastUsed: null, }; const created = (await SimpleDBOps.insert( sshCredentials, "ssh_credentials", credentialData, userId, )) as typeof credentialData & { id: number }; authLogger.success( `SSH credential created: ${name} (${authType}) by user ${userId}`, { operation: "credential_create_success", userId, credentialId: created.id, name, authType, username, }, ); res.status(201).json(formatCredentialOutput(created)); } catch (err) { authLogger.error("Failed to create credential in database", err, { operation: "credential_create", userId, name, authType, username, }); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to create credential", }); } }, ); /** * @openapi * /credentials: * get: * summary: Get all credentials * description: Retrieves all SSH credentials for the authenticated user. * tags: * - Credentials * responses: * 200: * description: A list of credentials. * 400: * description: Invalid userId. * 500: * description: Failed to fetch credentials. */ router.get( "/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId for credential fetch"); return res.status(400).json({ error: "Invalid userId" }); } try { const credentials = await SimpleDBOps.select( db .select() .from(sshCredentials) .where(eq(sshCredentials.userId, userId)) .orderBy(desc(sshCredentials.updatedAt)), "ssh_credentials", userId, ); res.json(credentials.map((cred) => formatCredentialOutput(cred))); } catch (err) { authLogger.error("Failed to fetch credentials", err); res.status(500).json({ error: "Failed to fetch credentials" }); } }, ); /** * @openapi * /credentials/folders: * get: * summary: Get credential folders * description: Retrieves all unique credential folders for the authenticated user. * tags: * - Credentials * responses: * 200: * description: A list of folder names. * 400: * description: Invalid userId. * 500: * description: Failed to fetch credential folders. */ router.get( "/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId for credential folder fetch"); return res.status(400).json({ error: "Invalid userId" }); } try { const result = await db .select({ folder: sshCredentials.folder }) .from(sshCredentials) .where(eq(sshCredentials.userId, userId)); const folderCounts: Record = {}; result.forEach((r) => { if (r.folder && r.folder.trim() !== "") { folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1; } }); const folders = Object.keys(folderCounts).filter( (folder) => folderCounts[folder] > 0, ); res.json(folders); } catch (err) { authLogger.error("Failed to fetch credential folders", err); res.status(500).json({ error: "Failed to fetch credential folders" }); } }, ); /** * @openapi * /credentials/{id}: * get: * summary: Get a specific credential * description: Retrieves a specific credential by its ID, including secrets. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The requested credential. * 400: * description: Invalid request. * 404: * description: Credential not found. * 500: * description: Failed to fetch credential. */ router.get( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!isNonEmptyString(userId) || !id) { authLogger.warn("Invalid request for credential fetch"); return res.status(400).json({ error: "Invalid request" }); } try { const credentials = await SimpleDBOps.select( db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length === 0) { return res.status(404).json({ error: "Credential not found" }); } const credential = credentials[0]; const output = formatCredentialOutput(credential); if (credential.password) { output.password = credential.password; } if (credential.key) { output.key = credential.key; } if (credential.privateKey) { output.privateKey = credential.privateKey; } if (credential.publicKey) { output.publicKey = credential.publicKey; } if (credential.keyPassword) { output.keyPassword = credential.keyPassword; } res.json(output); } catch (err) { authLogger.error("Failed to fetch credential", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch credential", }); } }, ); /** * @openapi * /credentials/{id}: * put: * summary: Update a credential * description: Updates a specific credential by its ID. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * description: * type: string * responses: * 200: * description: The updated credential. * 400: * description: Invalid request. * 404: * description: Credential not found. * 500: * description: Failed to update credential. */ router.put( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const updateData = req.body; if (!isNonEmptyString(userId) || !id) { authLogger.warn("Invalid request for credential update"); return res.status(400).json({ error: "Invalid request" }); } authLogger.info("Updating SSH credential", { operation: "credential_update", userId, credentialId: parseInt(id), changes: Object.keys(updateData), }); try { const existing = await db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.userId, userId), ), ); if (existing.length === 0) { return res.status(404).json({ error: "Credential not found" }); } const updateFields: Record = {}; if (updateData.name !== undefined) updateFields.name = updateData.name.trim(); if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; if (updateData.tags !== undefined) { updateFields.tags = Array.isArray(updateData.tags) ? updateData.tags.join(",") : updateData.tags || ""; } if (updateData.username !== undefined) updateFields.username = updateData.username?.trim() || null; if (updateData.authType !== undefined) updateFields.authType = updateData.authType; if (updateData.keyType !== undefined) updateFields.keyType = updateData.keyType; if (updateData.password !== undefined) { updateFields.password = updateData.password || null; } if (updateData.key !== undefined) { updateFields.key = updateData.key || null; if (updateData.key && existing[0].authType === "key") { const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword); if (!keyInfo.success) { authLogger.warn("SSH key parsing failed during update", { operation: "credential_update", userId, credentialId: parseInt(id), error: keyInfo.error, }); return res.status(400).json({ error: `Invalid SSH key: ${keyInfo.error}`, }); } updateFields.privateKey = keyInfo.privateKey; updateFields.publicKey = keyInfo.publicKey; updateFields.detectedKeyType = keyInfo.keyType; } } if (updateData.keyPassword !== undefined) { updateFields.keyPassword = updateData.keyPassword || null; } if (Object.keys(updateFields).length === 0) { const existing = await SimpleDBOps.select( db .select() .from(sshCredentials) .where(eq(sshCredentials.id, parseInt(id))), "ssh_credentials", userId, ); return res.json(formatCredentialOutput(existing[0])); } await SimpleDBOps.update( sshCredentials, "ssh_credentials", and( eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.userId, userId), ), updateFields, userId, ); const updated = await SimpleDBOps.select( db .select() .from(sshCredentials) .where(eq(sshCredentials.id, parseInt(id))), "ssh_credentials", userId, ); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.updateSharedCredentialsForOriginal( parseInt(id), userId, ); authLogger.success("SSH credential updated", { operation: "credential_update_success", userId, credentialId: parseInt(id), }); res.json(formatCredentialOutput(updated[0])); } catch (err) { authLogger.error("Failed to update credential", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to update credential", }); } }, ); /** * @openapi * /credentials/{id}: * delete: * summary: Delete a credential * description: Deletes a specific credential by its ID. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Credential deleted successfully. * 400: * description: Invalid request. * 404: * description: Credential not found. * 500: * description: Failed to delete credential. */ router.delete( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!isNonEmptyString(userId) || !id) { authLogger.warn("Invalid request for credential deletion"); return res.status(400).json({ error: "Invalid request" }); } authLogger.info("Deleting SSH credential", { operation: "credential_delete", userId, credentialId: parseInt(id), }); try { const credentialToDelete = await db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.userId, userId), ), ); if (credentialToDelete.length === 0) { return res.status(404).json({ error: "Credential not found" }); } const hostsUsingCredential = await db .select() .from(hosts) .where( and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), ); if (hostsUsingCredential.length > 0) { await db .update(hosts) .set({ credentialId: null, password: null, key: null, keyPassword: null, authType: "password", }) .where( and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)), ); for (const host of hostsUsingCredential) { const revokedShares = await db .delete(hostAccess) .where(eq(hostAccess.hostId, host.id)) .returning({ id: hostAccess.id }); if (revokedShares.length > 0) { authLogger.info( "Auto-revoked host shares due to credential deletion", { operation: "auto_revoke_shares", hostId: host.id, credentialId: parseInt(id), revokedCount: revokedShares.length, reason: "credential_deleted", }, ); } } } const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id)); await db .delete(sshCredentials) .where( and( eq(sshCredentials.id, parseInt(id)), eq(sshCredentials.userId, userId), ), ); authLogger.success("SSH credential deleted", { operation: "credential_delete_success", userId, credentialId: parseInt(id), }); res.json({ message: "Credential deleted successfully" }); } catch (err) { authLogger.error("Failed to delete credential", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to delete credential", }); } }, ); /** * @openapi * /credentials/{id}/apply-to-host/{hostId}: * post: * summary: Apply a credential to a host * description: Applies a credential to an SSH host for quick application. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * - in: path * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: Credential applied to host successfully. * 400: * description: Invalid request. * 404: * description: Credential not found. * 500: * description: Failed to apply credential to host. */ router.post( "/:id/apply-to-host/:hostId", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const credentialId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const hostId = Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId; if (!isNonEmptyString(userId) || !credentialId || !hostId) { authLogger.warn("Invalid request for credential application"); return res.status(400).json({ error: "Invalid request" }); } try { const credentials = await SimpleDBOps.select( db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, parseInt(credentialId)), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length === 0) { return res.status(404).json({ error: "Credential not found" }); } const credential = credentials[0]; await db .update(hosts) .set({ credentialId: parseInt(credentialId), username: (credential.username as string) || "", authType: credential.authType as string, password: null, key: null, keyPassword: null, keyType: null, updatedAt: new Date().toISOString(), }) .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))); await db.insert(sshCredentialUsage).values({ credentialId: parseInt(credentialId), hostId: parseInt(hostId), userId, }); await db .update(sshCredentials) .set({ usageCount: sql`${sshCredentials.usageCount} + 1`, lastUsed: new Date().toISOString(), updatedAt: new Date().toISOString(), }) .where(eq(sshCredentials.id, parseInt(credentialId))); res.json({ message: "Credential applied to host successfully" }); } catch (err) { authLogger.error("Failed to apply credential to host", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to apply credential to host", }); } }, ); /** * @openapi * /credentials/{id}/hosts: * get: * summary: Get hosts using a credential * description: Retrieves a list of hosts that are using a specific credential. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: A list of hosts. * 400: * description: Invalid request. * 500: * description: Failed to fetch hosts using credential. */ router.get( "/:id/hosts", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const credentialId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!isNonEmptyString(userId) || !credentialId) { authLogger.warn("Invalid request for credential hosts fetch"); return res.status(400).json({ error: "Invalid request" }); } try { const hostsUsingCredential = await db .select() .from(hosts) .where( and( eq(hosts.credentialId, parseInt(credentialId)), eq(hosts.userId, userId), ), ); res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host))); } catch (err) { authLogger.error("Failed to fetch hosts using credential", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch hosts using credential", }); } }, ); function formatCredentialOutput( credential: Record, ): Record { return { id: credential.id, name: credential.name, description: credential.description, folder: credential.folder, tags: typeof credential.tags === "string" ? credential.tags ? credential.tags.split(",").filter(Boolean) : [] : [], authType: credential.authType, username: credential.username || null, publicKey: credential.publicKey, keyType: credential.keyType, detectedKeyType: credential.detectedKeyType, usageCount: credential.usageCount || 0, lastUsed: credential.lastUsed, createdAt: credential.createdAt, updatedAt: credential.updatedAt, }; } function formatSSHHostOutput( host: Record, ): Record { return { id: host.id, userId: host.userId, name: host.name, ip: host.ip, port: host.port, username: host.username, folder: host.folder, tags: typeof host.tags === "string" ? host.tags ? host.tags.split(",").filter(Boolean) : [] : [], pin: !!host.pin, authType: host.authType, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], enableFileManager: !!host.enableFileManager, defaultPath: host.defaultPath, createdAt: host.createdAt, updatedAt: host.updatedAt, }; } /** * @openapi * /credentials/folders/rename: * put: * summary: Rename a credential folder * description: Renames a credential folder for the authenticated user. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * oldName: * type: string * newName: * type: string * responses: * 200: * description: Folder renamed successfully. * 400: * description: Both oldName and newName are required. * 500: * description: Failed to rename folder. */ router.put( "/folders/rename", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { oldName, newName } = req.body; if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) { return res .status(400) .json({ error: "Both oldName and newName are required" }); } if (oldName === newName) { return res .status(400) .json({ error: "Old name and new name cannot be the same" }); } try { await db .update(sshCredentials) .set({ folder: newName }) .where( and( eq(sshCredentials.userId, userId), eq(sshCredentials.folder, oldName), ), ); res.json({ success: true, message: "Folder renamed successfully" }); } catch (error) { authLogger.error("Error renaming credential folder:", error); res.status(500).json({ error: "Failed to rename folder" }); } }, ); /** * @openapi * /credentials/detect-key-type: * post: * summary: Detect SSH key type * description: Detects the type of an SSH private key. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * privateKey: * type: string * keyPassword: * type: string * responses: * 200: * description: Key type detection result. * 400: * description: Private key is required. * 500: * description: Failed to detect key type. */ router.post( "/detect-key-type", authenticateJWT, async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; if (!privateKey || typeof privateKey !== "string") { return res.status(400).json({ error: "Private key is required" }); } try { const keyInfo = parseSSHKey(privateKey, keyPassword); const response = { success: keyInfo.success, keyType: keyInfo.keyType, detectedKeyType: keyInfo.keyType, hasPublicKey: !!keyInfo.publicKey, error: keyInfo.error || null, }; res.json(response); } catch (error) { authLogger.error("Failed to detect key type", error); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to detect key type", }); } }, ); /** * @openapi * /credentials/detect-public-key-type: * post: * summary: Detect SSH public key type * description: Detects the type of an SSH public key. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * publicKey: * type: string * responses: * 200: * description: Key type detection result. * 400: * description: Public key is required. * 500: * description: Failed to detect public key type. */ router.post( "/detect-public-key-type", authenticateJWT, async (req: Request, res: Response) => { const { publicKey } = req.body; if (!publicKey || typeof publicKey !== "string") { return res.status(400).json({ error: "Public key is required" }); } try { const keyInfo = parsePublicKey(publicKey); const response = { success: keyInfo.success, keyType: keyInfo.keyType, detectedKeyType: keyInfo.keyType, error: keyInfo.error || null, }; res.json(response); } catch (error) { authLogger.error("Failed to detect public key type", error); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to detect public key type", }); } }, ); /** * @openapi * /credentials/validate-key-pair: * post: * summary: Validate SSH key pair * description: Validates if a given SSH private key and public key match. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * privateKey: * type: string * publicKey: * type: string * keyPassword: * type: string * responses: * 200: * description: Key pair validation result. * 400: * description: Private key and public key are required. * 500: * description: Failed to validate key pair. */ router.post( "/validate-key-pair", authenticateJWT, async (req: Request, res: Response) => { const { privateKey, publicKey, keyPassword } = req.body; if (!privateKey || typeof privateKey !== "string") { return res.status(400).json({ error: "Private key is required" }); } if (!publicKey || typeof publicKey !== "string") { return res.status(400).json({ error: "Public key is required" }); } try { const validationResult = validateKeyPair( privateKey, publicKey, keyPassword, ); const response = { isValid: validationResult.isValid, privateKeyType: validationResult.privateKeyType, publicKeyType: validationResult.publicKeyType, generatedPublicKey: validationResult.generatedPublicKey, error: validationResult.error || null, }; res.json(response); } catch (error) { authLogger.error("Failed to validate key pair", error); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to validate key pair", }); } }, ); /** * @openapi * /credentials/generate-key-pair: * post: * summary: Generate new SSH key pair * description: Generates a new SSH key pair. * tags: * - Credentials * requestBody: * content: * application/json: * schema: * type: object * properties: * keyType: * type: string * keySize: * type: integer * passphrase: * type: string * responses: * 200: * description: The new key pair. * 500: * description: Failed to generate SSH key pair. */ router.post( "/generate-key-pair", authenticateJWT, async (req: Request, res: Response) => { const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body; try { const result = generateSSHKeyPair(keyType, keySize, passphrase); if (result.success && result.privateKey && result.publicKey) { const response = { success: true, privateKey: result.privateKey, publicKey: result.publicKey, keyType: keyType, format: "ssh", algorithm: keyType, keySize: keyType === "ssh-rsa" ? keySize : undefined, curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined, }; res.json(response); } else { res.status(500).json({ success: false, error: result.error || "Failed to generate SSH key pair", }); } } catch (error) { authLogger.error("Failed to generate key pair", error); res.status(500).json({ success: false, error: error instanceof Error ? error.message : "Failed to generate key pair", }); } }, ); /** * @openapi * /credentials/generate-public-key: * post: * summary: Generate public key from private key * description: Generates a public key from a given private key. * tags: * - Credentials * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * privateKey: * type: string * keyPassword: * type: string * responses: * 200: * description: The generated public key. * 400: * description: Private key is required. * 500: * description: Failed to generate public key. */ router.post( "/generate-public-key", authenticateJWT, async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; if (!privateKey || typeof privateKey !== "string") { return res.status(400).json({ error: "Private key is required" }); } try { let privateKeyObj; const parseAttempts = []; try { privateKeyObj = crypto.createPrivateKey({ key: privateKey, passphrase: keyPassword, }); } catch (error) { parseAttempts.push(`Method 1 (with passphrase): ${error.message}`); } if (!privateKeyObj) { try { privateKeyObj = crypto.createPrivateKey(privateKey); } catch (error) { parseAttempts.push(`Method 2 (without passphrase): ${error.message}`); } } if (!privateKeyObj) { try { privateKeyObj = crypto.createPrivateKey({ key: privateKey, format: "pem", type: "pkcs8", }); } catch (error) { parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`); } } if ( !privateKeyObj && privateKey.includes("-----BEGIN RSA PRIVATE KEY-----") ) { try { privateKeyObj = crypto.createPrivateKey({ key: privateKey, format: "pem", type: "pkcs1", }); } catch (error) { parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`); } } if ( !privateKeyObj && privateKey.includes("-----BEGIN EC PRIVATE KEY-----") ) { try { privateKeyObj = crypto.createPrivateKey({ key: privateKey, format: "pem", type: "sec1", }); } catch (error) { parseAttempts.push(`Method 5 (SEC1): ${error.message}`); } } if (!privateKeyObj) { try { const keyInfo = parseSSHKey(privateKey, keyPassword); if (keyInfo.success && keyInfo.publicKey) { const publicKeyString = String(keyInfo.publicKey); return res.json({ success: true, publicKey: publicKeyString, keyType: keyInfo.keyType, }); } else { parseAttempts.push( `SSH2 fallback: ${keyInfo.error || "No public key generated"}`, ); } } catch (error) { parseAttempts.push(`SSH2 fallback exception: ${error.message}`); } } if (!privateKeyObj) { return res.status(400).json({ success: false, error: "Unable to parse private key. Tried multiple formats.", details: parseAttempts, }); } const publicKeyObj = crypto.createPublicKey(privateKeyObj); const publicKeyPem = publicKeyObj.export({ type: "spki", format: "pem", }); const publicKeyString = typeof publicKeyPem === "string" ? publicKeyPem : publicKeyPem.toString("utf8"); let keyType = "unknown"; const asymmetricKeyType = privateKeyObj.asymmetricKeyType; if (asymmetricKeyType === "rsa") { keyType = "ssh-rsa"; } else if (asymmetricKeyType === "ed25519") { keyType = "ssh-ed25519"; } else if (asymmetricKeyType === "ec") { keyType = "ecdsa-sha2-nistp256"; } let finalPublicKey = publicKeyString; let formatType = "pem"; try { const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword); if (!(ssh2PrivateKey instanceof Error)) { const publicKeyBuffer = ssh2PrivateKey.getPublicSSH(); const base64Data = publicKeyBuffer.toString("base64"); finalPublicKey = `${keyType} ${base64Data}`; formatType = "ssh"; } } catch { // Ignore validation errors } const response = { success: true, publicKey: finalPublicKey, keyType: keyType, format: formatType, }; res.json(response); } catch (error) { authLogger.error("Failed to generate public key", error); res.status(500).json({ success: false, error: error instanceof Error ? error.message : "Failed to generate public key", }); } }, ); async function deploySSHKeyToHost( hostConfig: Record, credData: CredentialBackend, ): Promise<{ success: boolean; message?: string; error?: string }> { const publicKey = credData.publicKey as string; return new Promise((resolve) => { const conn = new Client(); const connectionTimeout = setTimeout(() => { conn.destroy(); resolve({ success: false, error: "Connection timeout" }); }, 120000); conn.on("ready", async () => { clearTimeout(connectionTimeout); try { await new Promise((resolveCmd, rejectCmd) => { const cmdTimeout = setTimeout(() => { rejectCmd(new Error("mkdir command timeout")); }, 10000); conn.exec( "test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh", (err, stream) => { if (err) { clearTimeout(cmdTimeout); return rejectCmd(err); } stream.on("close", (code) => { clearTimeout(cmdTimeout); if (code === 0) { resolveCmd(); } else { rejectCmd( new Error(`mkdir command failed with code ${code}`), ); } }); stream.on("data", () => { // Ignore output }); }, ); }); const keyExists = await new Promise( (resolveCheck, rejectCheck) => { const checkTimeout = setTimeout(() => { rejectCheck(new Error("Key check timeout")); }, 5000); let actualPublicKey = publicKey; try { const parsed = JSON.parse(publicKey); if (parsed.data) { actualPublicKey = parsed.data; } } catch { // Ignore parse errors } const keyParts = actualPublicKey.trim().split(" "); if (keyParts.length < 2) { clearTimeout(checkTimeout); return rejectCheck( new Error( "Invalid public key format - must contain at least 2 parts", ), ); } const keyPattern = keyParts[1]; conn.exec( `if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`, (err, stream) => { if (err) { clearTimeout(checkTimeout); return rejectCheck(err); } let output = ""; stream.on("data", (data) => { output += data.toString(); }); stream.on("close", () => { clearTimeout(checkTimeout); const exists = output.trim() === "0"; resolveCheck(exists); }); }, ); }, ); if (keyExists) { conn.end(); resolve({ success: true, message: "SSH key already deployed" }); return; } await new Promise((resolveAdd, rejectAdd) => { const addTimeout = setTimeout(() => { rejectAdd(new Error("Key add timeout")); }, 30000); let actualPublicKey = publicKey; try { const parsed = JSON.parse(publicKey); if (parsed.data) { actualPublicKey = parsed.data; } } catch { // Ignore parse errors } const escapedKey = actualPublicKey .replace(/\\/g, "\\\\") .replace(/'/g, "'\\''"); conn.exec( `printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { if (err) { clearTimeout(addTimeout); return rejectAdd(err); } stream.on("data", () => { // Consume output }); stream.on("close", (code) => { clearTimeout(addTimeout); if (code === 0) { resolveAdd(); } else { rejectAdd( new Error(`Key deployment failed with code ${code}`), ); } }); }, ); }); const verifySuccess = await new Promise( (resolveVerify, rejectVerify) => { const verifyTimeout = setTimeout(() => { rejectVerify(new Error("Key verification timeout")); }, 5000); let actualPublicKey = publicKey; try { const parsed = JSON.parse(publicKey); if (parsed.data) { actualPublicKey = parsed.data; } } catch { // Ignore parse errors } const keyParts = actualPublicKey.trim().split(" "); if (keyParts.length < 2) { clearTimeout(verifyTimeout); return rejectVerify( new Error( "Invalid public key format - must contain at least 2 parts", ), ); } const keyPattern = keyParts[1]; conn.exec( `grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`, (err, stream) => { if (err) { clearTimeout(verifyTimeout); return rejectVerify(err); } let output = ""; stream.on("data", (data) => { output += data.toString(); }); stream.on("close", () => { clearTimeout(verifyTimeout); const verified = output.trim() === "0"; resolveVerify(verified); }); }, ); }, ); conn.end(); if (verifySuccess) { resolve({ success: true, message: "SSH key deployed successfully" }); } else { resolve({ success: false, error: "Key deployment verification failed", }); } } catch (error) { conn.end(); resolve({ success: false, error: error instanceof Error ? error.message : "Deployment failed", }); } }); conn.on("error", (err) => { clearTimeout(connectionTimeout); let errorMessage = err.message; if ( err.message.includes("All configured authentication methods failed") ) { errorMessage = "Authentication failed. Please check your credentials and ensure the SSH service is running."; } else if ( err.message.includes("ENOTFOUND") || err.message.includes("ENOENT") ) { errorMessage = "Could not resolve hostname or connect to server."; } else if (err.message.includes("ECONNREFUSED")) { errorMessage = "Connection refused. The server may not be running or the port may be incorrect."; } else if (err.message.includes("ETIMEDOUT")) { errorMessage = "Connection timed out. Check your network connection and server availability."; } else if ( err.message.includes("authentication failed") || err.message.includes("Permission denied") ) { errorMessage = "Authentication failed. Please check your username and password/key."; } 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"], }, }; 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"); } 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; } } catch (keyError) { clearTimeout(connectionTimeout); resolve({ success: false, error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, }); return; } } else { clearTimeout(connectionTimeout); resolve({ success: false, error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`, }); return; } conn.connect(connectionConfig); } catch (error) { clearTimeout(connectionTimeout); resolve({ success: false, error: error instanceof Error ? error.message : "Connection failed", }); } }); } /** * @openapi * /credentials/{id}/deploy-to-host: * post: * summary: Deploy SSH key to a host * description: Deploys an SSH public key to a target host's authorized_keys file. * tags: * - Credentials * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * targetHostId: * type: integer * responses: * 200: * description: SSH key deployed successfully. * 400: * description: Credential ID and target host ID are required. * 401: * description: Authentication required. * 404: * description: Credential or target host not found. * 500: * description: Failed to deploy SSH key. */ router.post( "/:id/deploy-to-host", authenticateJWT, async (req: Request, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const credentialId = parseInt(id); const { targetHostId } = req.body; if (!credentialId || !targetHostId) { return res.status(400).json({ success: false, error: "Credential ID and target host ID are required", }); } try { const userId = (req as AuthenticatedRequest).userId; if (!userId) { return res.status(401).json({ success: false, error: "Authentication required", }); } const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); const credential = await SimpleDBOps.select( db .select() .from(sshCredentials) .where(eq(sshCredentials.id, credentialId)) .limit(1), "ssh_credentials", userId, ); if (!credential || credential.length === 0) { return res.status(404).json({ success: false, error: "Credential not found", }); } const credData = credential[0] as unknown as CredentialBackend; if (credData.authType !== "key") { return res.status(400).json({ success: false, error: "Only SSH key-based credentials can be deployed", }); } const publicKey = credData.publicKey; if (!publicKey) { return res.status(400).json({ success: false, error: "Public key is required for deployment", }); } const targetHost = await SimpleDBOps.select( db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1), "ssh_data", userId, ); if (!targetHost || targetHost.length === 0) { return res.status(404).json({ success: false, error: "Target host not found", }); } const hostData = targetHost[0]; const hostConfig = { ip: hostData.ip, port: hostData.port, username: hostData.username, authType: hostData.authType, password: hostData.password, privateKey: hostData.key, keyPassword: hostData.keyPassword, }; if (hostData.authType === "credential" && hostData.credentialId) { const userId = (req as AuthenticatedRequest).userId; if (!userId) { return res.status(400).json({ success: false, error: "Authentication required for credential resolution", }); } try { const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); const hostCredential = await SimpleDBOps.select( db .select() .from(sshCredentials) .where(eq(sshCredentials.id, hostData.credentialId as number)) .limit(1), "ssh_credentials", userId, ); if (hostCredential && hostCredential.length > 0) { const cred = hostCredential[0]; hostConfig.authType = cred.authType; hostConfig.username = cred.username; if (cred.authType === "password") { hostConfig.password = cred.password; } else if (cred.authType === "key") { hostConfig.privateKey = cred.privateKey || cred.key; hostConfig.keyPassword = cred.keyPassword; } } else { return res.status(400).json({ success: false, error: "Host credential not found", }); } } catch { return res.status(500).json({ success: false, error: "Failed to resolve host credentials", }); } } const deployResult = await deploySSHKeyToHost(hostConfig, credData); if (deployResult.success) { res.json({ success: true, message: deployResult.message || "SSH key deployed successfully", }); } else { res.status(500).json({ success: false, error: deployResult.error || "Deployment failed", }); } } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : "Failed to deploy SSH key", }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/host.ts ================================================ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { hosts, sshCredentials, sshCredentialUsage, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, sshFolders, commandHistory, recentActivity, hostAccess, userRoles, sessionRecordings, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray, } from "drizzle-orm"; import type { Request, Response } from "express"; import multer from "multer"; import { sshLogger, databaseLogger } from "../../utils/logger.js"; import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { SystemCrypto } from "../../utils/system-crypto.js"; import { DatabaseSaveTrigger } from "../db/index.js"; import { parseSSHKey } from "../../utils/ssh-key-utils.js"; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function isValidPort(port: unknown): port is number { return typeof port === "number" && port > 0 && port <= 65535; } function transformHostResponse( host: Record, ): Record { return { ...host, tags: typeof host.tags === "string" ? host.tags ? host.tags.split(",").filter(Boolean) : [] : [], pin: !!host.pin, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: !!host.enableFileManager, enableDocker: !!host.enableDocker, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], quickActions: host.quickActions ? JSON.parse(host.quickActions as string) : [], statsConfig: host.statsConfig ? JSON.parse(host.statsConfig as string) : undefined, terminalConfig: host.terminalConfig ? JSON.parse(host.terminalConfig as string) : undefined, dockerConfig: host.dockerConfig ? JSON.parse(host.dockerConfig as string) : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", socks5ProxyChain: host.socks5ProxyChain ? JSON.parse(host.socks5ProxyChain as string) : [], domain: host.domain || undefined, security: host.security || undefined, ignoreCert: !!host.ignoreCert, guacamoleConfig: host.guacamoleConfig ? JSON.parse(host.guacamoleConfig as string) : undefined, }; } const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); /** * @openapi * /ssh/db/host/internal: * get: * summary: Get internal SSH host data * description: Returns internal SSH host data for autostart tunnels. Requires internal auth token. * tags: * - SSH * responses: * 200: * description: A list of autostart hosts. * 403: * description: Forbidden. * 500: * description: Failed to fetch autostart SSH data. */ router.get("/db/host/internal", async (req: Request, res: Response) => { try { const internalToken = req.headers["x-internal-auth-token"]; const systemCrypto = SystemCrypto.getInstance(); const expectedToken = await systemCrypto.getInternalAuthToken(); if (internalToken !== expectedToken) { sshLogger.warn( "Unauthorized attempt to access internal SSH host endpoint", { source: req.ip, userAgent: req.headers["user-agent"], providedToken: internalToken ? "present" : "missing", }, ); return res.status(403).json({ error: "Forbidden" }); } } catch (error) { sshLogger.error("Failed to validate internal auth token", error); return res.status(500).json({ error: "Internal server error" }); } try { const autostartHosts = await db .select() .from(hosts) .where( and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)), ); const result = autostartHosts .map((host) => { const tunnelConnections = host.tunnelConnections ? JSON.parse(host.tunnelConnections) : []; const hasAutoStartTunnels = tunnelConnections.some( (tunnel: Record) => tunnel.autoStart, ); if (!hasAutoStartTunnels) { return null; } return { id: host.id, userId: host.userId, name: host.name || `autostart-${host.id}`, ip: host.ip, port: host.port, username: host.username, password: host.autostartPassword, key: host.autostartKey, keyPassword: host.autostartKeyPassword, autostartPassword: host.autostartPassword, autostartKey: host.autostartKey, autostartKeyPassword: host.autostartKeyPassword, authType: host.authType, keyType: host.keyType, credentialId: host.credentialId, enableTunnel: true, tunnelConnections: tunnelConnections.filter( (tunnel: Record) => tunnel.autoStart, ), pin: !!host.pin, enableTerminal: !!host.enableTerminal, enableFileManager: !!host.enableFileManager, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, tags: ["autostart"], }; }) .filter(Boolean); res.json(result); } catch (err) { sshLogger.error("Failed to fetch autostart SSH data", err); res.status(500).json({ error: "Failed to fetch autostart SSH data" }); } }); /** * @openapi * /ssh/db/host/internal/all: * get: * summary: Get all internal SSH host data * description: Returns all internal SSH host data. Requires internal auth token. * tags: * - SSH * responses: * 200: * description: A list of all hosts. * 401: * description: Invalid or missing internal authentication token. * 500: * description: Failed to fetch all hosts. */ router.get("/db/host/internal/all", async (req: Request, res: Response) => { try { const internalToken = req.headers["x-internal-auth-token"]; if (!internalToken) { return res .status(401) .json({ error: "Internal authentication token required" }); } const systemCrypto = SystemCrypto.getInstance(); const expectedToken = await systemCrypto.getInternalAuthToken(); if (internalToken !== expectedToken) { return res .status(401) .json({ error: "Invalid internal authentication token" }); } const allHosts = await db.select().from(hosts); const result = allHosts.map((host) => { const tunnelConnections = host.tunnelConnections ? JSON.parse(host.tunnelConnections) : []; return { id: host.id, userId: host.userId, name: host.name || `${host.username}@${host.ip}`, ip: host.ip, port: host.port, username: host.username, password: host.autostartPassword || host.password, key: host.autostartKey || host.key, keyPassword: host.autostartKeyPassword || host.keyPassword, autostartPassword: host.autostartPassword, autostartKey: host.autostartKey, autostartKeyPassword: host.autostartKeyPassword, authType: host.authType, keyType: host.keyType, credentialId: host.credentialId, enableTunnel: !!host.enableTunnel, tunnelConnections: tunnelConnections, pin: !!host.pin, enableTerminal: !!host.enableTerminal, enableFileManager: !!host.enableFileManager, showTerminalInSidebar: !!host.showTerminalInSidebar, showFileManagerInSidebar: !!host.showFileManagerInSidebar, showTunnelInSidebar: !!host.showTunnelInSidebar, showDockerInSidebar: !!host.showDockerInSidebar, showServerStatsInSidebar: !!host.showServerStatsInSidebar, defaultPath: host.defaultPath, createdAt: host.createdAt, updatedAt: host.updatedAt, }; }); res.json(result); } catch (err) { sshLogger.error("Failed to fetch all hosts for internal use", err); res.status(500).json({ error: "Failed to fetch all hosts" }); } }); /** * @openapi * /ssh/db/host: * post: * summary: Create SSH host * description: Creates a new SSH host configuration. * tags: * - SSH * responses: * 200: * description: Host created successfully. * 400: * description: Invalid SSH data. * 500: * description: Failed to save SSH data. */ router.post( "/db/host", authenticateJWT, requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; let hostData: Record; if (req.headers["content-type"]?.includes("multipart/form-data")) { if (req.body.data) { try { hostData = JSON.parse(req.body.data); } catch (err) { sshLogger.warn("Invalid JSON data in multipart request", { operation: "host_create", userId, error: err, }); return res.status(400).json({ error: "Invalid JSON data" }); } } else { sshLogger.warn("Missing data field in multipart request", { operation: "host_create", userId, }); return res.status(400).json({ error: "Missing data field" }); } if (req.file) { hostData.key = req.file.buffer.toString("utf8"); } } else { hostData = req.body; } const { connectionType, name, folder, tags, ip, port, username, password, authMethod, authType, credentialId, key, keyPassword, keyType, sudoPassword, pin, enableTerminal, enableTunnel, enableFileManager, enableDocker, showTerminalInSidebar, showFileManagerInSidebar, showTunnelInSidebar, showDockerInSidebar, showServerStatsInSidebar, defaultPath, tunnelConnections, jumpHosts, quickActions, statsConfig, dockerConfig, terminalConfig, forceKeyboardInteractive, domain, security, ignoreCert, guacamoleConfig, notes, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, overrideCredentialUsername, } = hostData; databaseLogger.info("Creating SSH host", { operation: "host_create", userId, name, ip, }); if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) ) { sshLogger.warn("Invalid SSH data input validation failed", { operation: "host_create", userId, hasIp: !!ip, port, isValidPort: isValidPort(port), }); return res.status(400).json({ error: "Invalid SSH data" }); } const effectiveAuthType = authType || authMethod; const effectiveConnectionType = connectionType || "ssh"; const sshDataObj: Record = { userId: userId, connectionType: effectiveConnectionType, name, folder: folder || null, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, username, authType: effectiveAuthType, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, quickActions: Array.isArray(quickActions) ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, enableDocker: enableDocker ? 1 : 0, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, showDockerInSidebar: showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? typeof statsConfig === "string" ? statsConfig : JSON.stringify(statsConfig) : null, dockerConfig: dockerConfig ? typeof dockerConfig === "string" ? dockerConfig : JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig : JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", domain: domain || null, security: security || null, ignoreCert: ignoreCert ? 1 : 0, guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null, notes: notes || null, sudoPassword: sudoPassword || null, useSocks5: useSocks5 ? 1 : 0, socks5Host: socks5Host || null, socks5Port: socks5Port || null, socks5Username: socks5Username || null, socks5Password: socks5Password || null, socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, }; // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided if (effectiveConnectionType !== "ssh") { sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "password") { sshDataObj.password = password || null; sshDataObj.key = null; sshDataObj.keyPassword = null; 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, ); if (!keyValidation.success) { sshLogger.warn("SSH key validation failed", { operation: "host_create", userId, name, ip, port, error: keyValidation.error, }); return res.status(400).json({ error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`, }); } } sshDataObj.key = key || null; sshDataObj.keyPassword = keyPassword || null; sshDataObj.keyType = keyType; sshDataObj.password = null; } else { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } try { const result = await SimpleDBOps.insert( hosts, "ssh_data", sshDataObj, userId, ); if (!result) { sshLogger.warn("No host returned after creation", { operation: "host_create", userId, name, ip, port, }); return res.status(500).json({ error: "Failed to create host" }); } const createdHost = result; const baseHost = transformHostResponse(createdHost); const resolvedHost = (await resolveHostCredentials(baseHost, userId)) || baseHost; databaseLogger.success("SSH host created", { operation: "host_create_success", userId, hostId: createdHost.id as number, name, }); try { const axios = (await import("axios")).default; const statsPort = 30005; await axios.post( `http://localhost:${statsPort}/host-updated`, { hostId: createdHost.id }, { headers: { Authorization: req.headers.authorization || "", Cookie: req.headers.cookie || "", }, timeout: 5000, }, ); } catch (err) { sshLogger.warn("Failed to notify stats server of new host", { operation: "host_create", hostId: createdHost.id as number, error: err instanceof Error ? err.message : String(err), }); } res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { operation: "host_create", userId, name, ip, port, authType: effectiveAuthType, }); res.status(500).json({ error: "Failed to save SSH data" }); } }, ); /** * @openapi * /ssh/quick-connect: * post: * summary: Create a temporary SSH connection without saving to database * description: Returns a temporary host configuration for immediate use * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - ip * - port * - username * - authType * properties: * ip: * type: string * description: SSH server IP or hostname * port: * type: number * description: SSH server port * username: * type: string * description: SSH username * authType: * type: string * enum: [password, key, credential] * description: Authentication method * password: * type: string * description: Password (required if authType is password) * key: * type: string * description: SSH private key (required if authType is key) * keyPassword: * type: string * description: SSH key password (optional) * keyType: * type: string * description: SSH key type * credentialId: * type: number * description: Credential ID (required if authType is credential) * overrideCredentialUsername: * type: boolean * description: Use provided username instead of credential username * responses: * 200: * description: Temporary host configuration created successfully * 400: * description: Invalid request data * 401: * description: Unauthorized * 403: * description: Forbidden * 404: * description: Credential not found * 500: * description: Server error */ router.post( "/quick-connect", authenticateJWT, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId; const { ip, port, username, authType, password, key, keyPassword, keyType, credentialId, overrideCredentialUsername, } = req.body; if ( !isNonEmptyString(ip) || !isValidPort(port) || !isNonEmptyString(username) || !authType ) { return res.status(400).json({ error: "Missing required fields" }); } try { let resolvedPassword = password; let resolvedKey = key; let resolvedKeyPassword = keyPassword; let resolvedKeyType = keyType; let resolvedAuthType = authType; let resolvedUsername = username; if (authType === "credential" && credentialId) { const credentials = await SimpleDBOps.select( db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (!credentials || credentials.length === 0) { return res.status(404).json({ error: "Credential not found" }); } const cred = credentials[0]; resolvedPassword = cred.password as string | undefined; resolvedKey = cred.privateKey as string | undefined; resolvedKeyPassword = cred.keyPassword as string | undefined; resolvedKeyType = cred.keyType as string | undefined; resolvedAuthType = cred.authType as string | undefined; if (!overrideCredentialUsername) { resolvedUsername = cred.username as string; } } const tempHost: Record = { id: -Date.now(), userId: userId, name: `${resolvedUsername}@${ip}:${port}`, ip, port: Number(port), username: resolvedUsername, folder: "", tags: [], pin: false, authType: resolvedAuthType || authType, password: resolvedPassword, key: resolvedKey, keyPassword: resolvedKeyPassword, keyType: resolvedKeyType, enableTerminal: true, enableTunnel: false, enableFileManager: true, enableDocker: false, showTerminalInSidebar: true, showFileManagerInSidebar: false, showTunnelInSidebar: false, showDockerInSidebar: false, showServerStatsInSidebar: false, defaultPath: "/", tunnelConnections: [], jumpHosts: [], quickActions: [], statsConfig: {}, notes: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; return res.status(200).json(tempHost); } catch (error) { sshLogger.error("Quick connect failed", error, { operation: "quick_connect", userId, ip, port, authType, }); return res .status(500) .json({ error: "Failed to create quick connection" }); } }, ); /** * @openapi * /ssh/db/host/{id}: * put: * summary: Update SSH host * description: Updates an existing SSH host configuration. * tags: * - SSH * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Host updated successfully. * 400: * description: Invalid SSH data. * 403: * description: Access denied. * 404: * description: Host not found. * 500: * description: Failed to update SSH data. */ router.put( "/db/host/:id", authenticateJWT, requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; let hostData: Record; if (req.headers["content-type"]?.includes("multipart/form-data")) { if (req.body.data) { try { hostData = JSON.parse(req.body.data); } catch (err) { sshLogger.warn("Invalid JSON data in multipart request", { operation: "host_update", hostId: parseInt(hostId), userId, error: err, }); return res.status(400).json({ error: "Invalid JSON data" }); } } else { sshLogger.warn("Missing data field in multipart request", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(400).json({ error: "Missing data field" }); } if (req.file) { hostData.key = req.file.buffer.toString("utf8"); } } else { hostData = req.body; } const { connectionType, name, folder, tags, ip, port, username, password, authMethod, authType, credentialId, key, keyPassword, keyType, sudoPassword, pin, enableTerminal, enableTunnel, enableFileManager, enableDocker, showTerminalInSidebar, showFileManagerInSidebar, showTunnelInSidebar, showDockerInSidebar, showServerStatsInSidebar, defaultPath, tunnelConnections, jumpHosts, quickActions, statsConfig, dockerConfig, terminalConfig, forceKeyboardInteractive, domain, security, ignoreCert, guacamoleConfig, notes, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, overrideCredentialUsername, } = hostData; databaseLogger.info("Updating SSH host", { operation: "host_update", userId, hostId: parseInt(hostId), changes: Object.keys(hostData), }); if ( !isNonEmptyString(userId) || !isNonEmptyString(ip) || !isValidPort(port) || !hostId ) { sshLogger.warn("Invalid SSH data input validation failed for update", { operation: "host_update", hostId: parseInt(hostId), userId, hasIp: !!ip, port, isValidPort: isValidPort(port), }); return res.status(400).json({ error: "Invalid SSH data" }); } const effectiveAuthType = authType || authMethod; const sshDataObj: Record = { connectionType: connectionType || "ssh", name, folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", ip, port, username, authType: effectiveAuthType, credentialId: credentialId || null, overrideCredentialUsername: overrideCredentialUsername ? 1 : 0, pin: pin ? 1 : 0, enableTerminal: enableTerminal ? 1 : 0, enableTunnel: enableTunnel ? 1 : 0, tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, quickActions: Array.isArray(quickActions) ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, enableDocker: enableDocker ? 1 : 0, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, showDockerInSidebar: showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? typeof statsConfig === "string" ? statsConfig : JSON.stringify(statsConfig) : null, dockerConfig: dockerConfig ? typeof dockerConfig === "string" ? dockerConfig : JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig : JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", domain: domain || null, security: security || null, ignoreCert: ignoreCert ? 1 : 0, guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null, notes: notes || null, sudoPassword: sudoPassword || null, useSocks5: useSocks5 ? 1 : 0, socks5Host: socks5Host || null, socks5Port: socks5Port || null, socks5Username: socks5Username || null, socks5Password: socks5Password || null, socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null, }; // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided if ((connectionType || "ssh") !== "ssh") { if (password) { sshDataObj.password = password; } sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } else if (effectiveAuthType === "password") { if (password) { sshDataObj.password = password; } sshDataObj.key = null; sshDataObj.keyPassword = null; 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, ); if (!keyValidation.success) { sshLogger.warn("SSH key validation failed", { operation: "host_update", hostId: parseInt(hostId), userId, name, ip, port, error: keyValidation.error, }); return res.status(400).json({ error: `Invalid SSH key: ${keyValidation.error || "Unable to parse key"}`, }); } sshDataObj.key = key; } if (keyPassword !== undefined) { sshDataObj.keyPassword = keyPassword || null; } if (keyType) { sshDataObj.keyType = keyType; } sshDataObj.password = null; } else { sshDataObj.password = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; } try { const accessInfo = await permissionManager.canAccessHost( userId, Number(hostId), "write", ); if (!accessInfo.hasAccess) { sshLogger.warn("User does not have permission to update host", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(403).json({ error: "Access denied" }); } if (!accessInfo.isOwner) { sshLogger.warn("Shared user attempted to update host (view-only)", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(403).json({ error: "Only the host owner can modify host configuration", }); } const hostRecord = await db .select({ userId: hosts.userId, credentialId: hosts.credentialId, authType: hosts.authType, }) .from(hosts) .where(eq(hosts.id, Number(hostId))) .limit(1); if (hostRecord.length === 0) { sshLogger.warn("Host not found for update", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "Host not found" }); } const ownerId = hostRecord[0].userId; if ( !accessInfo.isOwner && sshDataObj.credentialId !== undefined && sshDataObj.credentialId !== hostRecord[0].credentialId ) { return res.status(403).json({ error: "Only the host owner can change the credential", }); } if ( !accessInfo.isOwner && sshDataObj.authType !== undefined && sshDataObj.authType !== hostRecord[0].authType ) { return res.status(403).json({ error: "Only the host owner can change the authentication type", }); } if (sshDataObj.credentialId !== undefined) { if ( hostRecord[0].credentialId !== null && sshDataObj.credentialId === null ) { await db .delete(hostAccess) .where(eq(hostAccess.hostId, Number(hostId))); } } await SimpleDBOps.update( hosts, "ssh_data", eq(hosts.id, Number(hostId)), sshDataObj, ownerId, ); const updatedHosts = await SimpleDBOps.select( db .select() .from(hosts) .where(eq(hosts.id, Number(hostId))), "ssh_data", ownerId, ); if (updatedHosts.length === 0) { sshLogger.warn("Updated host not found after update", { operation: "host_update", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "Host not found after update" }); } const updatedHost = updatedHosts[0]; const baseHost = transformHostResponse(updatedHost); const resolvedHost = (await resolveHostCredentials(baseHost, userId)) || baseHost; databaseLogger.success("SSH host updated", { operation: "host_update_success", userId, hostId: parseInt(hostId), }); try { const axios = (await import("axios")).default; const statsPort = 30005; await axios.post( `http://localhost:${statsPort}/host-updated`, { hostId: parseInt(hostId) }, { headers: { Authorization: req.headers.authorization || "", Cookie: req.headers.cookie || "", }, timeout: 5000, }, ); } catch (err) { sshLogger.warn("Failed to notify stats server of host update", { operation: "host_update", hostId: parseInt(hostId), error: err instanceof Error ? err.message : String(err), }); } res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { operation: "host_update", hostId: parseInt(hostId), userId, name, ip, port, authType: effectiveAuthType, }); res.status(500).json({ error: "Failed to update SSH data" }); } }, ); /** * @openapi * /ssh/db/host: * get: * summary: Get all SSH hosts * description: Retrieves all SSH hosts for the authenticated user. * tags: * - SSH * responses: * 200: * description: A list of SSH hosts. * 400: * description: Invalid userId. * 500: * description: Failed to fetch SSH data. */ router.get( "/db/host", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { sshLogger.warn("Invalid userId for SSH data fetch", { operation: "host_fetch", userId, }); return res.status(400).json({ error: "Invalid userId" }); } try { const now = new Date().toISOString(); const userRoleIds = await db .select({ roleId: userRoles.roleId }) .from(userRoles) .where(eq(userRoles.userId, userId)); const roleIds = userRoleIds.map((r) => r.roleId); const rawData = await db .select({ id: hosts.id, userId: hosts.userId, connectionType: hosts.connectionType, name: hosts.name, ip: hosts.ip, port: hosts.port, username: hosts.username, folder: hosts.folder, tags: hosts.tags, pin: hosts.pin, authType: hosts.authType, password: hosts.password, key: hosts.key, keyPassword: hosts.keyPassword, keyType: hosts.keyType, enableTerminal: hosts.enableTerminal, enableTunnel: hosts.enableTunnel, tunnelConnections: hosts.tunnelConnections, jumpHosts: hosts.jumpHosts, enableFileManager: hosts.enableFileManager, defaultPath: hosts.defaultPath, autostartPassword: hosts.autostartPassword, autostartKey: hosts.autostartKey, autostartKeyPassword: hosts.autostartKeyPassword, forceKeyboardInteractive: hosts.forceKeyboardInteractive, statsConfig: hosts.statsConfig, terminalConfig: hosts.terminalConfig, sudoPassword: hosts.sudoPassword, createdAt: hosts.createdAt, updatedAt: hosts.updatedAt, credentialId: hosts.credentialId, overrideCredentialUsername: hosts.overrideCredentialUsername, quickActions: hosts.quickActions, notes: hosts.notes, enableDocker: hosts.enableDocker, showTerminalInSidebar: hosts.showTerminalInSidebar, showFileManagerInSidebar: hosts.showFileManagerInSidebar, showTunnelInSidebar: hosts.showTunnelInSidebar, showDockerInSidebar: hosts.showDockerInSidebar, showServerStatsInSidebar: hosts.showServerStatsInSidebar, useSocks5: hosts.useSocks5, socks5Host: hosts.socks5Host, socks5Port: hosts.socks5Port, socks5Username: hosts.socks5Username, socks5Password: hosts.socks5Password, socks5ProxyChain: hosts.socks5ProxyChain, domain: hosts.domain, security: hosts.security, ignoreCert: hosts.ignoreCert, guacamoleConfig: hosts.guacamoleConfig, ownerId: hosts.userId, isShared: sql`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, }) .from(hosts) .leftJoin( hostAccess, and( eq(hostAccess.hostId, hosts.id), or( eq(hostAccess.userId, userId), roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`, ), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .where( or( eq(hosts.userId, userId), and( eq(hostAccess.userId, userId), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), roleIds.length > 0 ? and( inArray(hostAccess.roleId, roleIds), or( isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now), ), ) : sql`false`, ), ); const ownHosts = rawData.filter((row) => row.userId === userId); const sharedHosts = rawData.filter((row) => row.userId !== userId); let decryptedOwnHosts: Record[] = []; try { decryptedOwnHosts = await SimpleDBOps.select( Promise.resolve(ownHosts), "ssh_data", userId, ); } catch (decryptError) { sshLogger.error("Failed to decrypt own hosts", decryptError, { operation: "host_fetch_own_decrypt_failed", userId, }); decryptedOwnHosts = []; } const sanitizedSharedHosts = sharedHosts; const data = [...decryptedOwnHosts, ...sanitizedSharedHosts]; const result = await Promise.all( data.map(async (row: Record) => { const baseHost = { ...transformHostResponse(row), isShared: !!row.isShared, permissionLevel: row.permissionLevel || undefined, sharedExpiresAt: row.expiresAt || undefined, }; const resolved = (await resolveHostCredentials(baseHost, userId)) || baseHost; return resolved; }), ); res.json(result); } catch (err) { sshLogger.error("Failed to fetch SSH hosts from database", err, { operation: "host_fetch", userId, }); res.status(500).json({ error: "Failed to fetch SSH data" }); } }, ); /** * @openapi * /ssh/db/host/{id}: * get: * summary: Get SSH host by ID * description: Retrieves a specific SSH host by its ID. * tags: * - SSH * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The requested SSH host. * 400: * description: Invalid userId or hostId. * 404: * description: SSH host not found. * 500: * description: Failed to fetch SSH host. */ router.get( "/db/host/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId) || !hostId) { sshLogger.warn("Invalid userId or hostId for SSH host fetch by ID", { operation: "host_fetch_by_id", hostId: parseInt(hostId), userId, }); return res.status(400).json({ error: "Invalid userId or hostId" }); } try { const data = await SimpleDBOps.select( db .select() .from(hosts) .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), "ssh_data", userId, ); if (data.length === 0) { sshLogger.warn("SSH host not found", { operation: "host_fetch_by_id", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "SSH host not found" }); } const host = data[0]; const result = transformHostResponse(host); res.json((await resolveHostCredentials(result, userId)) || result); } catch (err) { sshLogger.error("Failed to fetch SSH host by ID from database", err, { operation: "host_fetch_by_id", hostId: parseInt(hostId), userId, }); res.status(500).json({ error: "Failed to fetch SSH host" }); } }, ); /** * @openapi * /ssh/db/host/{id}/export: * get: * summary: Export SSH host * description: Exports a specific SSH host with decrypted credentials. * tags: * - SSH * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The exported SSH host. * 400: * description: Invalid userId or hostId. * 404: * description: SSH host not found. * 500: * description: Failed to export SSH host. */ router.get( "/db/host/:id/export", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId) || !hostId) { return res.status(400).json({ error: "Invalid userId or hostId" }); } try { const hostResults = await SimpleDBOps.select( db .select() .from(hosts) .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResults.length === 0) { return res.status(404).json({ error: "SSH host not found" }); } const host = hostResults[0]; const resolvedHost = (await resolveHostCredentials(host, userId)) || host; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes( exportedConnectionType, ); const baseExportData = { connectionType: exportedConnectionType, name: resolvedHost.name, ip: resolvedHost.ip, port: resolvedHost.port, username: resolvedHost.username, password: resolvedHost.password || null, folder: resolvedHost.folder, tags: typeof resolvedHost.tags === "string" ? resolvedHost.tags.split(",").filter(Boolean) : resolvedHost.tags || [], pin: !!resolvedHost.pin, notes: resolvedHost.notes || null, }; const exportData = isRemoteDesktop ? { ...baseExportData, domain: resolvedHost.domain || null, security: resolvedHost.security || null, ignoreCert: !!resolvedHost.ignoreCert, guacamoleConfig: resolvedHost.guacamoleConfig ? JSON.parse(resolvedHost.guacamoleConfig as string) : null, } : { ...baseExportData, authType: resolvedHost.authType, key: resolvedHost.key || null, keyPassword: resolvedHost.keyPassword || null, keyType: resolvedHost.keyType || null, credentialId: resolvedHost.credentialId || null, overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername, enableTerminal: !!resolvedHost.enableTerminal, enableTunnel: !!resolvedHost.enableTunnel, enableFileManager: !!resolvedHost.enableFileManager, enableDocker: !!resolvedHost.enableDocker, showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar, showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar, showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar, showDockerInSidebar: !!resolvedHost.showDockerInSidebar, showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar, defaultPath: resolvedHost.defaultPath, sudoPassword: resolvedHost.sudoPassword || null, tunnelConnections: resolvedHost.tunnelConnections ? JSON.parse(resolvedHost.tunnelConnections as string) : [], jumpHosts: resolvedHost.jumpHosts ? JSON.parse(resolvedHost.jumpHosts as string) : null, quickActions: resolvedHost.quickActions ? JSON.parse(resolvedHost.quickActions as string) : null, statsConfig: resolvedHost.statsConfig ? JSON.parse(resolvedHost.statsConfig as string) : null, dockerConfig: resolvedHost.dockerConfig ? JSON.parse(resolvedHost.dockerConfig as string) : null, terminalConfig: resolvedHost.terminalConfig ? JSON.parse(resolvedHost.terminalConfig as string) : null, forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true", useSocks5: !!resolvedHost.useSocks5, socks5Host: resolvedHost.socks5Host || null, socks5Port: resolvedHost.socks5Port || null, socks5Username: resolvedHost.socks5Username || null, socks5Password: resolvedHost.socks5Password || null, socks5ProxyChain: resolvedHost.socks5ProxyChain ? JSON.parse(resolvedHost.socks5ProxyChain as string) : null, }; sshLogger.success("Host exported with decrypted credentials", { operation: "host_export", hostId: parseInt(hostId), userId, }); res.json(exportData); } catch (err) { sshLogger.error("Failed to export SSH host", err, { operation: "host_export", hostId: parseInt(hostId), userId, }); res.status(500).json({ error: "Failed to export SSH host" }); } }, ); /** * @openapi * /ssh/db/host/{id}: * delete: * summary: Delete SSH host * description: Deletes an SSH host by its ID. * tags: * - SSH * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: SSH host deleted successfully. * 400: * description: Invalid userId or id. * 404: * description: SSH host not found. * 500: * description: Failed to delete SSH host. */ router.delete( "/db/host/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!isNonEmptyString(userId) || !hostId) { sshLogger.warn("Invalid userId or hostId for SSH host delete", { operation: "host_delete", hostId: parseInt(hostId), userId, }); return res.status(400).json({ error: "Invalid userId or id" }); } databaseLogger.info("Deleting SSH host", { operation: "host_delete", userId, hostId: parseInt(hostId), }); try { const hostToDelete = await db .select() .from(hosts) .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))); if (hostToDelete.length === 0) { sshLogger.warn("SSH host not found for deletion", { operation: "host_delete", hostId: parseInt(hostId), userId, }); return res.status(404).json({ error: "SSH host not found" }); } const numericHostId = Number(hostId); await db .delete(fileManagerRecent) .where(eq(fileManagerRecent.hostId, numericHostId)); await db .delete(fileManagerPinned) .where(eq(fileManagerPinned.hostId, numericHostId)); await db .delete(fileManagerShortcuts) .where(eq(fileManagerShortcuts.hostId, numericHostId)); await db .delete(commandHistory) .where(eq(commandHistory.hostId, numericHostId)); await db .delete(sshCredentialUsage) .where(eq(sshCredentialUsage.hostId, numericHostId)); await db .delete(recentActivity) .where(eq(recentActivity.hostId, numericHostId)); await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId)); await db .delete(sessionRecordings) .where(eq(sessionRecordings.hostId, numericHostId)); await db .delete(hosts) .where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId))); databaseLogger.success("SSH host deleted", { operation: "host_delete_success", userId, hostId: parseInt(hostId), }); try { const axios = (await import("axios")).default; const statsPort = 30005; await axios.post( `http://localhost:${statsPort}/host-deleted`, { hostId: numericHostId }, { headers: { Authorization: req.headers.authorization || "", Cookie: req.headers.cookie || "", }, timeout: 5000, }, ); } catch (err) { sshLogger.warn("Failed to notify stats server of host deletion", { operation: "host_delete", hostId: numericHostId, error: err instanceof Error ? err.message : String(err), }); } res.json({ message: "SSH host deleted" }); } catch (err) { sshLogger.error("Failed to delete SSH host from database", err, { operation: "host_delete", hostId: parseInt(hostId), userId, }); res.status(500).json({ error: "Failed to delete SSH host" }); } }, ); /** * @openapi * /ssh/file_manager/recent: * get: * summary: Get recent files * description: Retrieves a list of recent files for a specific host. * tags: * - SSH * parameters: * - in: query * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: A list of recent files. * 400: * description: Invalid userId or hostId. * 500: * description: Failed to fetch recent files. */ router.get( "/file_manager/recent", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) ? req.query.hostId[0] : req.query.hostId; const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null; if (!isNonEmptyString(userId)) { sshLogger.warn("Invalid userId for recent files fetch"); return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { sshLogger.warn("Host ID is required for recent files fetch"); return res.status(400).json({ error: "Host ID is required" }); } try { const recentFiles = await db .select() .from(fileManagerRecent) .where( and( eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, hostId), ), ) .orderBy(desc(fileManagerRecent.lastOpened)) .limit(20); res.json(recentFiles); } catch (err) { sshLogger.error("Failed to fetch recent files", err); res.status(500).json({ error: "Failed to fetch recent files" }); } }, ); /** * @openapi * /ssh/file_manager/recent: * post: * summary: Add recent file * description: Adds a file to the list of recent files for a host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * name: * type: string * responses: * 200: * description: Recent file added. * 400: * description: Invalid data. * 500: * description: Failed to add recent file. */ router.post( "/file_manager/recent", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for recent file addition"); return res.status(400).json({ error: "Invalid data" }); } try { const existing = await db .select() .from(fileManagerRecent) .where( and( eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, hostId), eq(fileManagerRecent.path, path), ), ); if (existing.length > 0) { await db .update(fileManagerRecent) .set({ lastOpened: new Date().toISOString() }) .where(eq(fileManagerRecent.id, existing[0].id)); } else { await db.insert(fileManagerRecent).values({ userId, hostId, path, name: name || path.split("/").pop() || "Unknown", lastOpened: new Date().toISOString(), }); } res.json({ message: "Recent file added" }); } catch (err) { sshLogger.error("Failed to add recent file", err); res.status(500).json({ error: "Failed to add recent file" }); } }, ); /** * @openapi * /ssh/file_manager/recent: * delete: * summary: Remove recent file * description: Removes a file from the list of recent files for a host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * responses: * 200: * description: Recent file removed. * 400: * description: Invalid data. * 500: * description: Failed to remove recent file. */ router.delete( "/file_manager/recent", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for recent file deletion"); return res.status(400).json({ error: "Invalid data" }); } try { await db .delete(fileManagerRecent) .where( and( eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, hostId), eq(fileManagerRecent.path, path), ), ); res.json({ message: "Recent file removed" }); } catch (err) { sshLogger.error("Failed to remove recent file", err); res.status(500).json({ error: "Failed to remove recent file" }); } }, ); /** * @openapi * /ssh/file_manager/pinned: * get: * summary: Get pinned files * description: Retrieves a list of pinned files for a specific host. * tags: * - SSH * parameters: * - in: query * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: A list of pinned files. * 400: * description: Invalid userId or hostId. * 500: * description: Failed to fetch pinned files. */ router.get( "/file_manager/pinned", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) ? req.query.hostId[0] : req.query.hostId; const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null; if (!isNonEmptyString(userId)) { sshLogger.warn("Invalid userId for pinned files fetch"); return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { sshLogger.warn("Host ID is required for pinned files fetch"); return res.status(400).json({ error: "Host ID is required" }); } try { const pinnedFiles = await db .select() .from(fileManagerPinned) .where( and( eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, hostId), ), ) .orderBy(desc(fileManagerPinned.pinnedAt)); res.json(pinnedFiles); } catch (err) { sshLogger.error("Failed to fetch pinned files", err); res.status(500).json({ error: "Failed to fetch pinned files" }); } }, ); /** * @openapi * /ssh/file_manager/pinned: * post: * summary: Add pinned file * description: Adds a file to the list of pinned files for a host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * name: * type: string * responses: * 200: * description: File pinned. * 400: * description: Invalid data. * 409: * description: File already pinned. * 500: * description: Failed to pin file. */ router.post( "/file_manager/pinned", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for pinned file addition"); return res.status(400).json({ error: "Invalid data" }); } try { const existing = await db .select() .from(fileManagerPinned) .where( and( eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, hostId), eq(fileManagerPinned.path, path), ), ); if (existing.length > 0) { return res.status(409).json({ error: "File already pinned" }); } await db.insert(fileManagerPinned).values({ userId, hostId, path, name: name || path.split("/").pop() || "Unknown", pinnedAt: new Date().toISOString(), }); res.json({ message: "File pinned" }); } catch (err) { sshLogger.error("Failed to pin file", err); res.status(500).json({ error: "Failed to pin file" }); } }, ); /** * @openapi * /ssh/file_manager/pinned: * delete: * summary: Remove pinned file * description: Removes a file from the list of pinned files for a host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * responses: * 200: * description: Pinned file removed. * 400: * description: Invalid data. * 500: * description: Failed to remove pinned file. */ router.delete( "/file_manager/pinned", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for pinned file deletion"); return res.status(400).json({ error: "Invalid data" }); } try { await db .delete(fileManagerPinned) .where( and( eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, hostId), eq(fileManagerPinned.path, path), ), ); res.json({ message: "Pinned file removed" }); } catch (err) { sshLogger.error("Failed to remove pinned file", err); res.status(500).json({ error: "Failed to remove pinned file" }); } }, ); /** * @openapi * /ssh/file_manager/shortcuts: * get: * summary: Get shortcuts * description: Retrieves a list of shortcuts for a specific host. * tags: * - SSH * parameters: * - in: query * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: A list of shortcuts. * 400: * description: Invalid userId or hostId. * 500: * description: Failed to fetch shortcuts. */ router.get( "/file_manager/shortcuts", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdQuery = Array.isArray(req.query.hostId) ? req.query.hostId[0] : req.query.hostId; const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null; if (!isNonEmptyString(userId)) { sshLogger.warn("Invalid userId for shortcuts fetch"); return res.status(400).json({ error: "Invalid userId" }); } if (!hostId) { sshLogger.warn("Host ID is required for shortcuts fetch"); return res.status(400).json({ error: "Host ID is required" }); } try { const shortcuts = await db .select() .from(fileManagerShortcuts) .where( and( eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, hostId), ), ) .orderBy(desc(fileManagerShortcuts.createdAt)); res.json(shortcuts); } catch (err) { sshLogger.error("Failed to fetch shortcuts", err); res.status(500).json({ error: "Failed to fetch shortcuts" }); } }, ); /** * @openapi * /ssh/file_manager/shortcuts: * post: * summary: Add shortcut * description: Adds a shortcut for a specific host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * name: * type: string * responses: * 200: * description: Shortcut added. * 400: * description: Invalid data. * 409: * description: Shortcut already exists. * 500: * description: Failed to add shortcut. */ router.post( "/file_manager/shortcuts", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path, name } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for shortcut addition"); return res.status(400).json({ error: "Invalid data" }); } try { const existing = await db .select() .from(fileManagerShortcuts) .where( and( eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, hostId), eq(fileManagerShortcuts.path, path), ), ); if (existing.length > 0) { return res.status(409).json({ error: "Shortcut already exists" }); } await db.insert(fileManagerShortcuts).values({ userId, hostId, path, name: name || path.split("/").pop() || "Unknown", createdAt: new Date().toISOString(), }); res.json({ message: "Shortcut added" }); } catch (err) { sshLogger.error("Failed to add shortcut", err); res.status(500).json({ error: "Failed to add shortcut" }); } }, ); /** * @openapi * /ssh/file_manager/shortcuts: * delete: * summary: Remove shortcut * description: Removes a shortcut for a specific host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * path: * type: string * responses: * 200: * description: Shortcut removed. * 400: * description: Invalid data. * 500: * description: Failed to remove shortcut. */ router.delete( "/file_manager/shortcuts", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, path } = req.body; if (!isNonEmptyString(userId) || !hostId || !path) { sshLogger.warn("Invalid data for shortcut deletion"); return res.status(400).json({ error: "Invalid data" }); } try { await db .delete(fileManagerShortcuts) .where( and( eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, hostId), eq(fileManagerShortcuts.path, path), ), ); res.json({ message: "Shortcut removed" }); } catch (err) { sshLogger.error("Failed to remove shortcut", err); res.status(500).json({ error: "Failed to remove shortcut" }); } }, ); /** * @openapi * /ssh/command-history/{hostId}: * get: * summary: Get command history * description: Retrieves the command history for a specific host. * tags: * - SSH * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: A list of commands. * 400: * description: Invalid userId or hostId. * 500: * description: Failed to fetch command history. */ router.get( "/command-history/:hostId", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostIdParam = Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId; const hostId = parseInt(hostIdParam, 10); if (!isNonEmptyString(userId) || !hostId) { sshLogger.warn("Invalid userId or hostId for command history fetch", { operation: "command_history_fetch", hostId, userId, }); return res.status(400).json({ error: "Invalid userId or hostId" }); } try { const history = await db .select({ id: commandHistory.id, command: commandHistory.command, }) .from(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostId), ), ) .orderBy(desc(commandHistory.executedAt)) .limit(200); res.json(history.map((h) => h.command)); } catch (err) { sshLogger.error("Failed to fetch command history from database", err, { operation: "command_history_fetch", hostId, userId, }); res.status(500).json({ error: "Failed to fetch command history" }); } }, ); /** * @openapi * /ssh/command-history: * delete: * summary: Delete command from history * description: Deletes a specific command from the history of a host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * command: * type: string * responses: * 200: * description: Command deleted from history. * 400: * description: Invalid data. * 500: * description: Failed to delete command. */ router.delete( "/command-history", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, command } = req.body; if (!isNonEmptyString(userId) || !hostId || !command) { sshLogger.warn("Invalid data for command history deletion", { operation: "command_history_delete", hostId, userId, }); return res.status(400).json({ error: "Invalid data" }); } try { await db .delete(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostId), eq(commandHistory.command, command), ), ); res.json({ message: "Command deleted from history" }); } catch (err) { sshLogger.error("Failed to delete command from history", err, { operation: "command_history_delete", hostId, userId, command, }); res.status(500).json({ error: "Failed to delete command" }); } }, ); async function resolveHostCredentials( host: Record, requestingUserId?: string, ): Promise> { try { if (host.credentialId && (host.userId || host.ownerId)) { const credentialId = host.credentialId as number; const ownerId = (host.ownerId || host.userId) as string; if (requestingUserId && requestingUserId !== ownerId) { try { const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id as number, requestingUserId, ); if (sharedCred) { const resolvedHost: Record = { ...host, password: sharedCred.password, key: sharedCred.key, keyPassword: sharedCred.keyPassword, keyType: sharedCred.keyType, }; if (!host.overrideCredentialUsername) { resolvedHost.username = sharedCred.username; } return resolvedHost; } } catch (sharedCredError) { sshLogger.warn( "Failed to get shared credential, falling back to owner credential", { operation: "resolve_shared_credential_fallback", hostId: host.id as number, requestingUserId, error: sharedCredError instanceof Error ? sharedCredError.message : "Unknown error", }, ); } } const credentials = await SimpleDBOps.select( db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", ownerId, ); if (credentials.length > 0) { const credential = credentials[0]; const resolvedHost: Record = { ...host, password: credential.password, key: credential.key, keyPassword: credential.keyPassword, keyType: credential.keyType, }; if (!host.overrideCredentialUsername) { resolvedHost.username = credential.username; } return resolvedHost; } } return { ...host }; } catch (error) { sshLogger.warn( `Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, ); return host; } } /** * @openapi * /ssh/folders/rename: * put: * summary: Rename folder * description: Renames a folder for SSH hosts and credentials. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * oldName: * type: string * newName: * type: string * responses: * 200: * description: Folder renamed successfully. * 400: * description: Old name and new name are required. * 500: * description: Failed to rename folder. */ router.put( "/folders/rename", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { oldName, newName } = req.body; if (!isNonEmptyString(userId) || !oldName || !newName) { sshLogger.warn("Invalid data for folder rename"); return res .status(400) .json({ error: "Old name and new name are required" }); } if (oldName === newName) { return res.json({ message: "Folder name unchanged" }); } try { const updatedHosts = await SimpleDBOps.update( hosts, "ssh_data", and(eq(hosts.userId, userId), eq(hosts.folder, oldName)), { folder: newName, updatedAt: new Date().toISOString(), }, userId, ); const updatedCredentials = await db .update(sshCredentials) .set({ folder: newName, updatedAt: new Date().toISOString(), }) .where( and( eq(sshCredentials.userId, userId), eq(sshCredentials.folder, oldName), ), ) .returning(); DatabaseSaveTrigger.triggerSave("folder_rename"); await db .update(sshFolders) .set({ name: newName, updatedAt: new Date().toISOString(), }) .where( and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)), ); res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, updatedCredentials: updatedCredentials.length, }); } catch (err) { sshLogger.error("Failed to rename folder", err, { operation: "folder_rename", userId, oldName, newName, }); res.status(500).json({ error: "Failed to rename folder" }); } }, ); /** * @openapi * /ssh/folders: * get: * summary: Get all folders * description: Retrieves all folders for the authenticated user. * tags: * - SSH * responses: * 200: * description: A list of folders. * 400: * description: Invalid user ID. * 500: * description: Failed to fetch folders. */ router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { return res.status(400).json({ error: "Invalid user ID" }); } try { const folders = await db .select() .from(sshFolders) .where(eq(sshFolders.userId, userId)); res.json(folders); } catch (err) { sshLogger.error("Failed to fetch folders", err, { operation: "fetch_folders", userId, }); res.status(500).json({ error: "Failed to fetch folders" }); } }); /** * @openapi * /ssh/folders/metadata: * put: * summary: Update folder metadata * description: Updates the metadata (color, icon) of a folder. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * color: * type: string * icon: * type: string * responses: * 200: * description: Folder metadata updated successfully. * 400: * description: Folder name is required. * 500: * description: Failed to update folder metadata. */ router.put( "/folders/metadata", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { name, color, icon } = req.body; if (!isNonEmptyString(userId) || !name) { return res.status(400).json({ error: "Folder name is required" }); } try { const existing = await db .select() .from(sshFolders) .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) .limit(1); if (existing.length > 0) { databaseLogger.info("Updating SSH folder", { operation: "folder_update", userId, folderId: existing[0].id, }); await db .update(sshFolders) .set({ color, icon, updatedAt: new Date().toISOString(), }) .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))); } else { databaseLogger.info("Creating SSH folder", { operation: "folder_create", userId, name, }); await db.insert(sshFolders).values({ userId, name, color, icon, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); } DatabaseSaveTrigger.triggerSave("folder_metadata_update"); res.json({ message: "Folder metadata updated successfully" }); } catch (err) { sshLogger.error("Failed to update folder metadata", err, { operation: "update_folder_metadata", userId, name, }); res.status(500).json({ error: "Failed to update folder metadata" }); } }, ); /** * @openapi * /ssh/folders/{name}/hosts: * delete: * summary: Delete all hosts in folder * description: Deletes all SSH hosts within a specific folder. * tags: * - SSH * parameters: * - in: path * name: name * required: true * schema: * type: string * responses: * 200: * description: Hosts deleted successfully. * 400: * description: Invalid folder name. * 500: * description: Failed to delete hosts in folder. */ router.delete( "/folders/:name/hosts", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const folderName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name; if (!isNonEmptyString(userId) || !folderName) { return res.status(400).json({ error: "Invalid folder name" }); } databaseLogger.info("Deleting SSH folder", { operation: "folder_delete", userId, folderId: folderName, }); try { const hostsToDelete = await db .select() .from(hosts) .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName))); if (hostsToDelete.length === 0) { return res.json({ message: "No hosts found in folder", deletedCount: 0, }); } const hostIds = hostsToDelete.map((host) => host.id); if (hostIds.length > 0) { await db .delete(fileManagerRecent) .where(inArray(fileManagerRecent.hostId, hostIds)); await db .delete(fileManagerPinned) .where(inArray(fileManagerPinned.hostId, hostIds)); await db .delete(fileManagerShortcuts) .where(inArray(fileManagerShortcuts.hostId, hostIds)); await db .delete(commandHistory) .where(inArray(commandHistory.hostId, hostIds)); await db .delete(sshCredentialUsage) .where(inArray(sshCredentialUsage.hostId, hostIds)); await db .delete(recentActivity) .where(inArray(recentActivity.hostId, hostIds)); await db.delete(hostAccess).where(inArray(hostAccess.hostId, hostIds)); await db .delete(sessionRecordings) .where(inArray(sessionRecordings.hostId, hostIds)); } await db .delete(hosts) .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName))); await db .delete(sshFolders) .where( and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)), ); DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); try { const axios = (await import("axios")).default; const statsPort = 30005; for (const host of hostsToDelete) { try { await axios.post( `http://localhost:${statsPort}/host-deleted`, { hostId: host.id }, { headers: { Authorization: req.headers.authorization || "", Cookie: req.headers.cookie || "", }, timeout: 5000, }, ); } catch (err) { sshLogger.warn("Failed to notify stats server of host deletion", { operation: "folder_hosts_delete", hostId: host.id, error: err instanceof Error ? err.message : String(err), }); } } } catch (err) { sshLogger.warn("Failed to notify stats server of folder deletion", { operation: "folder_hosts_delete", folderName, error: err instanceof Error ? err.message : String(err), }); } res.json({ message: "All hosts in folder deleted successfully", deletedCount: hostsToDelete.length, }); } catch (err) { sshLogger.error("Failed to delete hosts in folder", err, { operation: "delete_folder_hosts", userId, folderName, }); res.status(500).json({ error: "Failed to delete hosts in folder" }); } }, ); /** * @openapi * /ssh/bulk-import: * post: * summary: Bulk import SSH hosts * description: Bulk imports multiple SSH hosts. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hosts: * type: array * items: * type: object * responses: * 200: * description: Import completed. * 400: * description: Invalid request body. */ /** * @swagger * /ssh/bulk-update: * patch: * summary: Bulk update partial fields on multiple SSH hosts * tags: [SSH] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostIds: * type: array * items: * type: number * updates: * type: object * responses: * 200: * description: Bulk update completed. * 400: * description: Invalid request body. */ router.patch( "/bulk-update", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostIds, updates } = req.body; if (!Array.isArray(hostIds) || hostIds.length === 0) { return res .status(400) .json({ error: "hostIds array is required and must not be empty" }); } if (hostIds.length > 1000) { return res .status(400) .json({ error: "Maximum 1000 hosts allowed per bulk update" }); } if ( !updates || typeof updates !== "object" || Object.keys(updates).length === 0 ) { return res.status(400).json({ error: "updates object is required and must contain at least one field", }); } try { const ownedHosts = await db .select({ id: hosts.id, statsConfig: hosts.statsConfig }) .from(hosts) .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); const ownedIds = ownedHosts.map((h) => h.id); const unauthorizedIds = hostIds.filter( (id: number) => !ownedIds.includes(id), ); if (ownedIds.length === 0) { return res.status(404).json({ error: "No matching hosts found" }); } const errors: string[] = []; if (unauthorizedIds.length > 0) { errors.push(`${unauthorizedIds.length} host(s) not found or not owned`); } const simpleUpdates: Record = {}; if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin; if (typeof updates.folder === "string") simpleUpdates.folder = updates.folder || null; if (typeof updates.enableTerminal === "boolean") simpleUpdates.enableTerminal = updates.enableTerminal; if (typeof updates.enableTunnel === "boolean") simpleUpdates.enableTunnel = updates.enableTunnel; if (typeof updates.enableFileManager === "boolean") simpleUpdates.enableFileManager = updates.enableFileManager; if (typeof updates.enableDocker === "boolean") simpleUpdates.enableDocker = updates.enableDocker; if (Object.keys(simpleUpdates).length > 0) { await db .update(hosts) .set(simpleUpdates) .where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId))); } if (updates.statsConfig && typeof updates.statsConfig === "object") { for (const host of ownedHosts) { try { const existing = host.statsConfig ? JSON.parse(host.statsConfig as string) : {}; const merged = { ...existing, ...updates.statsConfig }; await db .update(hosts) .set({ statsConfig: JSON.stringify(merged) }) .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); } catch (e) { errors.push(`Failed to update statsConfig for host ${host.id}`); } } } DatabaseSaveTrigger.triggerSave("bulk_update"); return res.json({ updated: ownedIds.length, failed: unauthorizedIds.length, errors, }); } catch (error) { sshLogger.error("Failed to bulk update hosts:", error); return res.status(500).json({ error: "Failed to bulk update hosts" }); } }, ); router.post( "/bulk-import", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hosts: hostsToImport, overwrite } = req.body; if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) { return res .status(400) .json({ error: "Hosts array is required and must not be empty" }); } if (hostsToImport.length > 100) { return res .status(400) .json({ error: "Maximum 100 hosts allowed per import" }); } const results = { success: 0, updated: 0, skipped: 0, failed: 0, errors: [] as string[], }; let existingHostMap: Map | undefined; if (overwrite) { try { const allHosts = await SimpleDBOps.select>( db.select().from(hosts).where(eq(hosts.userId, userId)), "ssh_data", userId, ); existingHostMap = new Map(); for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number }); } } catch { existingHostMap = undefined; } } for (let i = 0; i < hostsToImport.length; i++) { const hostData = hostsToImport[i]; try { const effectiveConnectionType = hostData.connectionType || "ssh"; if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) { results.failed++; results.errors.push( `Host ${i + 1}: Missing required fields (ip, port)`, ); continue; } if ( effectiveConnectionType === "ssh" && !isNonEmptyString(hostData.username) ) { results.failed++; results.errors.push( `Host ${i + 1}: Username required for SSH connections`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType && !["password", "key", "credential", "none", "opkssh"].includes( hostData.authType, ) ) { results.failed++; results.errors.push( `Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', or 'opkssh'`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "password" && !isNonEmptyString(hostData.password) ) { results.failed++; results.errors.push( `Host ${i + 1}: Password required for password authentication`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "key" && !isNonEmptyString(hostData.key) ) { results.failed++; results.errors.push( `Host ${i + 1}: Key required for key authentication`, ); continue; } if ( effectiveConnectionType === "ssh" && hostData.authType === "credential" && !hostData.credentialId ) { results.failed++; results.errors.push( `Host ${i + 1}: credentialId required for credential authentication`, ); continue; } const sshDataObj: Record = { userId: userId, connectionType: effectiveConnectionType, name: hostData.name || `${hostData.username || ""}@${hostData.ip}`, folder: hostData.folder || "Default", tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "", ip: hostData.ip, port: hostData.port, username: hostData.username || null, pin: hostData.pin || false, enableTerminal: hostData.enableTerminal !== false, enableTunnel: hostData.enableTunnel !== false, enableFileManager: hostData.enableFileManager !== false, enableDocker: hostData.enableDocker || false, showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0, showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0, showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0, defaultPath: hostData.defaultPath || "/", sudoPassword: hostData.sudoPassword || null, tunnelConnections: hostData.tunnelConnections ? JSON.stringify(hostData.tunnelConnections) : "[]", jumpHosts: hostData.jumpHosts ? JSON.stringify(hostData.jumpHosts) : null, quickActions: hostData.quickActions ? JSON.stringify(hostData.quickActions) : null, statsConfig: hostData.statsConfig ? JSON.stringify(hostData.statsConfig) : null, dockerConfig: hostData.dockerConfig ? JSON.stringify(hostData.dockerConfig) : null, terminalConfig: hostData.terminalConfig ? JSON.stringify(hostData.terminalConfig) : null, forceKeyboardInteractive: hostData.forceKeyboardInteractive ? "true" : "false", notes: hostData.notes || null, useSocks5: hostData.useSocks5 ? 1 : 0, socks5Host: hostData.socks5Host || null, socks5Port: hostData.socks5Port || null, socks5Username: hostData.socks5Username || null, socks5Password: hostData.socks5Password || null, socks5ProxyChain: hostData.socks5ProxyChain ? JSON.stringify(hostData.socks5ProxyChain) : null, overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0, updatedAt: new Date().toISOString(), }; if (effectiveConnectionType !== "ssh") { sshDataObj.password = hostData.password || null; sshDataObj.authType = "password"; sshDataObj.credentialId = null; sshDataObj.key = null; sshDataObj.keyPassword = null; sshDataObj.keyType = null; sshDataObj.domain = hostData.domain || null; sshDataObj.security = hostData.security || null; sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0; sshDataObj.guacamoleConfig = hostData.guacamoleConfig ? JSON.stringify(hostData.guacamoleConfig) : null; } else { sshDataObj.password = hostData.authType === "password" ? hostData.password : null; sshDataObj.authType = hostData.authType || "password"; sshDataObj.credentialId = hostData.authType === "credential" ? hostData.credentialId : null; sshDataObj.key = hostData.authType === "key" ? hostData.key : null; sshDataObj.keyPassword = hostData.authType === "key" ? hostData.keyPassword || null : null; sshDataObj.keyType = hostData.authType === "key" ? hostData.keyType || "auto" : null; sshDataObj.domain = null; sshDataObj.security = null; sshDataObj.ignoreCert = 0; sshDataObj.guacamoleConfig = null; } const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; const existing = existingHostMap?.get(lookupKey); if (existing) { await SimpleDBOps.update( hosts, "ssh_data", eq(hosts.id, existing.id), sshDataObj, userId, ); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId); results.success++; } } catch (error) { results.failed++; results.errors.push( `Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, updated: results.updated, skipped: results.skipped, failed: results.failed, errors: results.errors, }); }, ); /** * @openapi * /ssh/folders/{folderName}/hosts: * delete: * summary: Delete all hosts in a folder * description: Deletes all hosts within a specific folder. * tags: * - SSH * parameters: * - in: path * name: folderName * required: true * schema: * type: string * responses: * 200: * description: All hosts deleted successfully. * 400: * description: Invalid folder name. * 500: * description: Failed to delete hosts. */ router.delete( "/folders/:folderName/hosts", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const folderName = decodeURIComponent( Array.isArray(req.params.folderName) ? req.params.folderName[0] : req.params.folderName, ); if (!folderName) { return res.status(400).json({ error: "Folder name is required" }); } try { const hostsToDelete = await db .select({ id: hosts.id }) .from(hosts) .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName))); if (hostsToDelete.length === 0) { return res.json({ deletedCount: 0 }); } const hostIds = hostsToDelete.map((h) => h.id); for (const hostId of hostIds) { await db .delete(fileManagerRecent) .where(eq(fileManagerRecent.hostId, hostId)); await db .delete(fileManagerPinned) .where(eq(fileManagerPinned.hostId, hostId)); await db .delete(fileManagerShortcuts) .where(eq(fileManagerShortcuts.hostId, hostId)); await db .delete(commandHistory) .where(eq(commandHistory.hostId, hostId)); await db .delete(sshCredentialUsage) .where(eq(sshCredentialUsage.hostId, hostId)); await db .delete(recentActivity) .where(eq(recentActivity.hostId, hostId)); await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId)); await db .delete(sessionRecordings) .where(eq(sessionRecordings.hostId, hostId)); } await db .delete(hosts) .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName))); databaseLogger.success("All hosts in folder deleted", { operation: "delete_folder_hosts", userId, folderName, deletedCount: hostsToDelete.length, }); res.json({ deletedCount: hostsToDelete.length }); } catch (error) { sshLogger.error("Failed to delete hosts in folder", error, { operation: "delete_folder_hosts", userId, folderName, }); res.status(500).json({ error: "Failed to delete hosts in folder" }); } }, ); /** * @openapi * /ssh/autostart/enable: * post: * summary: Enable autostart for SSH configuration * description: Enables autostart for a specific SSH configuration. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sshConfigId: * type: number * responses: * 200: * description: AutoStart enabled successfully. * 400: * description: Valid sshConfigId is required. * 404: * description: SSH configuration not found. * 500: * description: Internal server error. */ router.post( "/autostart/enable", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { sshConfigId } = req.body; if (!sshConfigId || typeof sshConfigId !== "number") { sshLogger.warn( "Missing or invalid sshConfigId in autostart enable request", { operation: "autostart_enable", userId, sshConfigId, }, ); return res.status(400).json({ error: "Valid sshConfigId is required" }); } try { const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { sshLogger.warn( "User attempted to enable autostart without unlocked data", { operation: "autostart_enable_failed", userId, sshConfigId, reason: "data_locked", }, ); return res.status(400).json({ error: "Failed to enable autostart. Ensure user data is unlocked.", }); } const sshConfig = await db .select() .from(hosts) .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); if (sshConfig.length === 0) { sshLogger.warn("SSH config not found for autostart enable", { operation: "autostart_enable_failed", userId, sshConfigId, reason: "config_not_found", }); return res.status(404).json({ error: "SSH configuration not found", }); } const config = sshConfig[0]; const decryptedConfig = DataCrypto.decryptRecord( "ssh_data", config, userId, userDataKey, ); let updatedTunnelConnections = config.tunnelConnections; if (config.tunnelConnections) { try { const tunnelConnections = JSON.parse(config.tunnelConnections); const resolvedConnections = await Promise.all( tunnelConnections.map(async (tunnel: Record) => { if ( tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey ) { const endpointHosts = await db .select() .from(hosts) .where(eq(hosts.userId, userId)); const endpointHost = endpointHosts.find( (h) => h.name === tunnel.endpointHost || `${h.username}@${h.ip}` === tunnel.endpointHost, ); if (endpointHost) { const decryptedEndpoint = DataCrypto.decryptRecord( "ssh_data", endpointHost, userId, userDataKey, ); return { ...tunnel, endpointPassword: decryptedEndpoint.password || null, endpointKey: decryptedEndpoint.key || null, endpointKeyPassword: decryptedEndpoint.keyPassword || null, endpointAuthType: endpointHost.authType, }; } } return tunnel; }), ); updatedTunnelConnections = JSON.stringify(resolvedConnections); } catch (error) { sshLogger.warn("Failed to update tunnel connections", { operation: "tunnel_connections_update_failed", error: error instanceof Error ? error.message : "Unknown error", }); } } await db .update(hosts) .set({ autostartPassword: decryptedConfig.password || null, autostartKey: decryptedConfig.key || null, autostartKeyPassword: decryptedConfig.keyPassword || null, tunnelConnections: updatedTunnelConnections, }) .where(eq(hosts.id, sshConfigId)); try { await DatabaseSaveTrigger.triggerSave(); } catch (saveError) { sshLogger.warn("Database save failed after autostart", { operation: "autostart_db_save_failed", error: saveError instanceof Error ? saveError.message : "Unknown error", }); } res.json({ message: "AutoStart enabled successfully", sshConfigId, }); } catch (error) { sshLogger.error("Error enabling autostart", error, { operation: "autostart_enable_error", userId, sshConfigId, }); res.status(500).json({ error: "Internal server error" }); } }, ); /** * @openapi * /ssh/autostart/disable: * delete: * summary: Disable autostart for SSH configuration * description: Disables autostart for a specific SSH configuration. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sshConfigId: * type: number * responses: * 200: * description: AutoStart disabled successfully. * 400: * description: Valid sshConfigId is required. * 500: * description: Internal server error. */ router.delete( "/autostart/disable", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { sshConfigId } = req.body; if (!sshConfigId || typeof sshConfigId !== "number") { sshLogger.warn( "Missing or invalid sshConfigId in autostart disable request", { operation: "autostart_disable", userId, sshConfigId, }, ); return res.status(400).json({ error: "Valid sshConfigId is required" }); } try { await db .update(hosts) .set({ autostartPassword: null, autostartKey: null, autostartKeyPassword: null, }) .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId))); res.json({ message: "AutoStart disabled successfully", sshConfigId, }); } catch (error) { sshLogger.error("Error disabling autostart", error, { operation: "autostart_disable_error", userId, sshConfigId, }); res.status(500).json({ error: "Internal server error" }); } }, ); /** * @openapi * /ssh/autostart/status: * get: * summary: Get autostart status * description: Retrieves the autostart status for the user's SSH configurations. * tags: * - SSH * responses: * 200: * description: A list of autostart configurations. * 500: * description: Internal server error. */ router.get( "/autostart/status", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { const autostartConfigs = await db .select() .from(hosts) .where( and( eq(hosts.userId, userId), or( isNotNull(hosts.autostartPassword), isNotNull(hosts.autostartKey), ), ), ); const statusList = autostartConfigs.map((config) => ({ sshConfigId: config.id, host: config.ip, port: config.port, username: config.username, authType: config.authType, })); res.json({ autostart_configs: statusList, total_count: statusList.length, }); } catch (error) { sshLogger.error("Error getting autostart status", error, { operation: "autostart_status_error", userId, }); res.status(500).json({ error: "Internal server error" }); } }, ); /** * @openapi * /ssh/opkssh/token/{hostId}: * get: * summary: Get OPKSSH token status for a host * tags: [SSH] * security: * - bearerAuth: [] * parameters: * - name: hostId * in: path * required: true * schema: * type: integer * description: Host ID * responses: * 200: * description: Token status retrieved successfully * content: * application/json: * schema: * type: object * properties: * exists: * type: boolean * description: Whether a valid token exists * expiresAt: * type: string * format: date-time * description: Token expiration timestamp * email: * type: string * description: User email from OIDC identity * 404: * description: No valid token found * 500: * description: Internal server error */ router.get( "/ssh/opkssh/token/:hostId", authenticateJWT, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId; const hostId = parseInt( Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId, ); if (!userId || isNaN(hostId)) { return res.status(400).json({ error: "Invalid request" }); } try { const { opksshTokens } = await import("../db/schema.js"); const token = await db .select() .from(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ) .limit(1); if (!token || token.length === 0) { return res.status(404).json({ exists: false }); } const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { await db .delete(opksshTokens) .where( and( eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId), ), ); return res.status(404).json({ exists: false }); } res.json({ exists: true, expiresAt: tokenData.expiresAt, email: tokenData.email, }); } catch (error) { sshLogger.error("Error retrieving OPKSSH token status", error, { operation: "opkssh_token_status_error", userId, hostId, }); res.status(500).json({ error: "Internal server error" }); } }, ); /** * @openapi * /ssh/opkssh/token/{hostId}: * delete: * summary: Delete OPKSSH token for a host * tags: [SSH] * security: * - bearerAuth: [] * parameters: * - name: hostId * in: path * required: true * schema: * type: integer * description: Host ID * responses: * 200: * description: Token deleted successfully * 500: * description: Internal server error */ router.delete( "/ssh/opkssh/token/:hostId", authenticateJWT, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId; const hostId = parseInt( Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId, ); if (!userId || isNaN(hostId)) { return res.status(400).json({ error: "Invalid request" }); } try { const { deleteOPKSSHToken } = await import("../../ssh/opkssh-auth.js"); await deleteOPKSSHToken(userId, hostId); res.json({ success: true }); } catch (error) { sshLogger.error("Error deleting OPKSSH token", error, { operation: "opkssh_token_delete_error", userId, hostId, }); res.status(500).json({ error: "Internal server error" }); } }, ); function rewriteOPKSSHHtml( html: string, requestId: string, routePrefix: "opkssh-chooser" | "opkssh-callback", ): string { const basePath = `/ssh/${routePrefix}/${requestId}`; const attrPatterns = ["action", "href", "src"]; for (const attr of attrPatterns) { html = html.replace( new RegExp(`${attr}="(/[^"]*)`, "g"), `${attr}="${basePath}$1`, ); html = html.replace( new RegExp(`${attr}='(/[^']*)`, "g"), `${attr}='${basePath}$1`, ); } html = html.replace( /href=["']?http:\/\/localhost:\d+\/([^"'\s]*)/g, `href="${basePath}/$1`, ); html = html.replace( /action=["']?http:\/\/localhost:\d+\/([^"'\s]*)/g, `action="${basePath}/$1`, ); html = html.replace( /src=["']?http:\/\/localhost:\d+\/([^"'\s]*)/g, `src="${basePath}/$1`, ); html = html.replace( /(window\.location\.href\s*=\s*["'])http:\/\/localhost:\d+\/([^"']*)(["'])/g, `$1${basePath}/$2$3`, ); html = html.replace( /(window\.location\s*=\s*["'])http:\/\/localhost:\d+\/([^"']*)(["'])/g, `$1${basePath}/$2$3`, ); html = html.replace( /(fetch\(["'])http:\/\/localhost:\d+\/([^"']*)(["'])/g, `$1${basePath}/$2$3`, ); html = html.replace( /(location\.assign\(["'])http:\/\/localhost:\d+\/([^"']*)(["']\))/g, `$1${basePath}/$2$3`, ); html = html.replace( /(location\.replace\(["'])http:\/\/localhost:\d+\/([^"']*)(["']\))/g, `$1${basePath}/$2$3`, ); html = html.replace( /(]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\s*url=)http:\/\/localhost:\d+\/([^"']+)(["'][^>]*>)/gi, `$1${basePath}/$2$3`, ); html = html.replace( /(data-[\w-]+=["'])http:\/\/localhost:\d+\/([^"']*)(["'])/g, `$1${basePath}/$2$3`, ); const baseTag = ``; if (html.includes("]*>/i, baseTag); } else if (html.includes("")) { sshLogger.info("Inserting base tag into head", { operation: "opkssh_html_rewrite_base_tag_insert", requestId, basePath, }); html = html.replace(//i, `${baseTag}`); } else { sshLogger.warn("No tag found, wrapping HTML", { operation: "opkssh_html_rewrite_no_head", requestId, htmlLength: html.length, htmlPreview: html.substring(0, 200), }); html = `${baseTag}${html}`; } sshLogger.info("HTML rewrite complete", { operation: "opkssh_html_rewrite_complete", requestId, routePrefix, hasBaseTag: html.includes(" { const requestId = Array.isArray(req.params.requestId) ? req.params.requestId[0] : req.params.requestId; const fullPath = req.originalUrl || req.url; const pathAfterRequestIdTemp = fullPath.split(`/ssh/opkssh-chooser/${requestId}`)[1] || ""; sshLogger.info("OPKSSH chooser proxy request", { operation: "opkssh_chooser_proxy_request", requestId, url: req.url, originalUrl: req.originalUrl, fullPath, pathAfterRequestId: pathAfterRequestIdTemp, method: req.method, }); try { const { getActiveAuthSession } = await import("../../ssh/opkssh-auth.js"); const session = getActiveAuthSession(requestId); if (!session) { sshLogger.error("Session not found for chooser request", { operation: "opkssh_chooser_session_not_found", requestId, }); res.status(404).send(` Session Not Found

Session Not Found

This authentication session has expired or is invalid.

`); return; } const axios = (await import("axios")).default; const fullPath = req.originalUrl || req.url; const pathAfterRequestId = fullPath.split(`/ssh/opkssh-chooser/${requestId}`)[1] || ""; const targetPath = pathAfterRequestId || "/chooser"; if (!session.localPort || session.localPort === 0) { sshLogger.error("OPKSSH session has no local port", { operation: "opkssh_chooser_proxy", requestId, sessionStatus: session.status, }); res.status(500).send(` Error

Authentication Error

Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.

`); return; } const targetUrl = `http://localhost:${session.localPort}${targetPath}`; sshLogger.info("Proxying to OPKSSH chooser", { operation: "opkssh_chooser_proxy_request_to_opkssh", requestId, targetUrl, localPort: session.localPort, targetPath, }); const response = await axios({ method: req.method, url: targetUrl, headers: { ...req.headers, host: `localhost:${session.localPort}`, }, data: req.body, timeout: 10000, validateStatus: () => true, maxRedirects: 0, responseType: "arraybuffer", }); sshLogger.info("OPKSSH chooser response received", { operation: "opkssh_chooser_proxy_response", requestId, statusCode: response.status, contentType: response.headers["content-type"], contentLength: response.headers["content-length"], hasLocation: !!response.headers.location, }); Object.entries(response.headers).forEach(([key, value]) => { if (key.toLowerCase() === "transfer-encoding") { return; } if (key.toLowerCase() === "location") { const location = value as string; if (location.startsWith("/")) { res.setHeader(key, `/ssh/opkssh-chooser/${requestId}${location}`); } else { const localhostMatch = location.match( /^http:\/\/localhost:(\d+)(\/.*)?$/, ); if (localhostMatch) { const port = parseInt(localhostMatch[1], 10); const path = localhostMatch[2] || "/"; if (session.callbackPort && port === session.callbackPort) { res.setHeader(key, `/ssh/opkssh-callback/${requestId}${path}`); } else if (port === session.localPort) { res.setHeader(key, `/ssh/opkssh-chooser/${requestId}${path}`); } else { const isCallback = path.includes("login") || path.includes("callback"); const prefix = isCallback ? "opkssh-callback" : "opkssh-chooser"; res.setHeader(key, `/ssh/${prefix}/${requestId}${path}`); } } else { res.setHeader(key, value as string); } } } else { res.setHeader(key, value as string); } }); const contentType = response.headers["content-type"] || ""; if (contentType.includes("text/html")) { const html = rewriteOPKSSHHtml( response.data.toString("utf-8"), requestId, "opkssh-chooser", ); res.status(response.status).send(html); } else { res.status(response.status).send(response.data); } } catch (error) { sshLogger.error("Error proxying OPKSSH chooser", error, { operation: "opkssh_chooser_proxy_error", requestId, }); res.status(500).send(` Error

Error

Failed to load authentication page. Please try again.

`); } }, ); /** * @openapi * /opkssh-callback: * get: * summary: Static OAuth callback from OIDC provider for OPKSSH authentication * tags: [SSH] * responses: * 200: * description: Callback processed successfully * 404: * description: No active authentication session found * 500: * description: Authentication failed */ router.get("/opkssh-callback", async (req: Request, res: Response) => { try { sshLogger.info("OAuth callback received", { operation: "opkssh_static_callback_received", url: req.url, originalUrl: req.originalUrl, query: req.query, headers: { host: req.headers.host, "x-forwarded-proto": req.headers["x-forwarded-proto"], "x-forwarded-host": req.headers["x-forwarded-host"], "x-forwarded-port": req.headers["x-forwarded-port"], }, }); const { getUserIdFromRequest, getActiveSessionsForUser } = await import("../../ssh/opkssh-auth.js"); const userId = await getUserIdFromRequest({ cookies: req.cookies, headers: req.headers as Record, }); sshLogger.info("User ID resolved", { operation: "opkssh_callback_user_lookup", userId: userId || "null", hasCookies: !!req.cookies?.jwt, cookieKeys: Object.keys(req.cookies || {}), }); if (!userId) { sshLogger.error("No userId from callback request", { operation: "opkssh_callback_unauthorized", cookies: Object.keys(req.cookies || {}), headers: Object.keys(req.headers), }); res.status(401).send("Unauthorized - no valid session"); return; } const userSessions = getActiveSessionsForUser(userId); sshLogger.info("Active sessions for user", { operation: "opkssh_callback_session_lookup", userId, sessionCount: userSessions.length, sessions: userSessions.map((s) => ({ requestId: s.requestId, status: s.status, hasCallbackPort: !!s.callbackPort, callbackPort: s.callbackPort, hasLocalPort: !!s.localPort, localPort: s.localPort, })), }); if (userSessions.length === 0) { sshLogger.error("No active sessions for callback", { operation: "opkssh_callback_no_sessions", userId, }); res.status(404).send("No active authentication session found"); return; } const session = userSessions[userSessions.length - 1]; if (!session.callbackPort) { sshLogger.error("Session callback port not ready", { operation: "opkssh_callback_port_not_ready", userId, requestId: session.requestId, sessionStatus: session.status, hasLocalPort: !!session.localPort, }); res.status(503).send("OPKSSH callback listener not ready yet"); return; } const queryString = req.url.includes("?") ? req.url.substring(req.url.indexOf("?")) : ""; const redirectUrl = `/ssh/opkssh-callback/${session.requestId}/login-callback${queryString}`; sshLogger.info("Redirecting OAuth callback to dynamic route", { operation: "opkssh_static_callback_redirect", userId, requestId: session.requestId, callbackPort: session.callbackPort, queryParams: Object.keys(req.query), redirectUrl, }); res.redirect(302, redirectUrl); } catch (error) { sshLogger.error("Error handling OPKSSH static callback", error, { operation: "opkssh_static_callback_error", url: req.url, originalUrl: req.originalUrl, }); res.status(500).send("Authentication callback failed"); } }); /** * @openapi * /opkssh-callback/{requestId}: * get: * summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths) * tags: [SSH] * parameters: * - name: requestId * in: path * required: true * schema: * type: string * description: Authentication request ID * responses: * 200: * description: Callback processed successfully * 404: * description: Invalid authentication session * 500: * description: Authentication failed */ router.use( "/opkssh-callback/:requestId", async (req: Request, res: Response) => { const requestId = Array.isArray(req.params.requestId) ? req.params.requestId[0] : req.params.requestId; try { const { getActiveAuthSession } = await import("../../ssh/opkssh-auth.js"); const session = getActiveAuthSession(requestId); if (!session) { res.status(404).send(` Session Not Found

Session Not Found

Authentication session expired or invalid.

Please close this window and try again.

`); return; } const axios = (await import("axios")).default; const fullPath = req.originalUrl || req.url; const pathAfterRequestId = fullPath.split(`/ssh/opkssh-callback/${requestId}`)[1] || ""; const targetPath = pathAfterRequestId || "/login-callback"; if (!session.callbackPort || session.callbackPort === 0) { sshLogger.error("OPKSSH callback session has no callback port", { operation: "opkssh_callback_proxy", requestId, sessionStatus: session.status, }); res.status(500).send(` Error

Callback Error

OPKSSH callback listener not ready. Please try authenticating again.

`); return; } const targetUrl = `http://localhost:${session.callbackPort}${targetPath}`; const response = await axios({ method: req.method, url: targetUrl, headers: { ...req.headers, host: `localhost:${session.callbackPort}`, }, data: req.body, timeout: 10000, validateStatus: () => true, maxRedirects: 0, responseType: "arraybuffer", }); Object.entries(response.headers).forEach(([key, value]) => { if (key.toLowerCase() === "transfer-encoding") { return; } if (key.toLowerCase() === "location") { const location = value as string; if (location.startsWith("/")) { res.setHeader(key, `/ssh/opkssh-callback/${requestId}${location}`); } else { res.setHeader(key, value as string); } } else { res.setHeader(key, value as string); } }); const contentType = response.headers["content-type"] || ""; if (contentType.includes("text/html")) { const html = rewriteOPKSSHHtml( response.data.toString("utf-8"), requestId, "opkssh-callback", ); res.status(response.status).send(html); } else { res.status(response.status).send(response.data); } } catch (error) { sshLogger.error("Error handling OPKSSH OAuth callback", error, { operation: "opkssh_oauth_callback_error", requestId, }); res.status(500).send(` Error

Error

An unexpected error occurred. Please try again.

`); } }, ); /** * @openapi * /db/proxy/test: * post: * summary: Test proxy connectivity * description: Tests connectivity through a proxy configuration to a target host. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * singleProxy: * type: object * properties: * host: * type: string * port: * type: number * type: * type: string * username: * type: string * password: * type: string * proxyChain: * type: array * items: * type: object * testTarget: * type: object * properties: * host: * type: string * port: * type: number * responses: * 200: * description: Test result * 500: * description: Proxy connection failed */ router.post( "/db/proxy/test", authenticateJWT, requireDataAccess, async (req: AuthenticatedRequest, res: Response) => { try { const { singleProxy, proxyChain, testTarget } = req.body; const { testProxyConnectivity } = await import("../../utils/proxy-helper.js"); const result = await testProxyConnectivity({ singleProxy, proxyChain, testTarget, }); res.json(result); } catch (error) { sshLogger.error("Proxy connectivity test failed", error, { operation: "proxy_test", userId: req.userId, }); res.status(500).json({ success: false, error: error instanceof Error ? error.message : "Unknown error", }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/network-topology.ts ================================================ import express from "express"; import { eq } from "drizzle-orm"; import { getDb } from "../db/index.js"; import { networkTopology } from "../db/schema.js"; import { AuthManager } from "../../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { databaseLogger } from "../../utils/logger.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); /** * @openapi * /network-topology: * get: * summary: Get network topology for authenticated user * description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists. * tags: * - Network Topology * security: * - bearerAuth: [] * responses: * 200: * description: Network topology retrieved successfully * content: * application/json: * schema: * type: object * nullable: true * properties: * nodes: * type: array * description: Array of graph nodes (hosts and groups) * items: * type: object * properties: * data: * type: object * description: Node data including id, label, type, etc. * position: * type: object * description: Node position coordinates * properties: * x: * type: number * y: * type: number * edges: * type: array * description: Array of graph edges (connections) * items: * type: object * properties: * data: * type: object * description: Edge data including source and target node ids * 401: * description: User not authenticated * 500: * description: Server error */ router.get( "/", authenticateJWT, async (req: express.Request, res: express.Response) => { try { const userId = (req as AuthenticatedRequest).userId; if (!userId) { return res.status(401).json({ error: "User not authenticated" }); } const db = getDb(); const result = await db .select() .from(networkTopology) .where(eq(networkTopology.userId, userId)); if (result.length > 0) { const topologyStr = result[0].topology; const topology = topologyStr ? JSON.parse(topologyStr) : null; return res.json(topology); } else { return res.json(null); } } catch (error) { databaseLogger.error("Failed to fetch network topology", error, { operation: "network_topology_fetch", userId: (req as AuthenticatedRequest).userId, }); return res.status(500).json({ error: "Failed to fetch network topology", details: (error as Error).message, }); } }, ); /** * @openapi * /network-topology: * post: * summary: Save network topology for authenticated user * description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise. * tags: * - Network Topology * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - topology * properties: * topology: * type: object * description: Network topology data containing nodes and edges * properties: * nodes: * type: array * description: Array of graph nodes (hosts and groups) * edges: * type: array * description: Array of graph edges (connections) * responses: * 200: * description: Topology saved successfully * content: * application/json: * schema: * type: object * properties: * success: * type: boolean * 400: * description: Invalid topology data * 401: * description: User not authenticated * 500: * description: Server error */ router.post( "/", authenticateJWT, async (req: express.Request, res: express.Response) => { try { const userId = (req as AuthenticatedRequest).userId; if (!userId) { return res.status(401).json({ error: "User not authenticated" }); } const { topology } = req.body; if (!topology) { return res.status(400).json({ error: "Topology data is required" }); } const db = getDb(); const topologyStr = typeof topology === "string" ? topology : JSON.stringify(topology); const existing = await db .select() .from(networkTopology) .where(eq(networkTopology.userId, userId)); if (existing.length > 0) { await db .update(networkTopology) .set({ topology: topologyStr }) .where(eq(networkTopology.userId, userId)); } else { await db .insert(networkTopology) .values({ userId, topology: topologyStr }); } return res.json({ success: true }); } catch (error) { databaseLogger.error("Failed to save network topology", error, { operation: "network_topology_save", userId: (req as AuthenticatedRequest).userId, }); return res.status(500).json({ error: "Failed to save network topology", details: (error as Error).message, }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/rbac.ts ================================================ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { hostAccess, hosts, users, roles, userRoles, sharedCredentials, } from "../db/schema.js"; import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm"; import type { Response } from "express"; import { databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } /** * @openapi * /rbac/host/{id}/share: * post: * summary: Share a host * description: Shares a host with a user or a role. * tags: * - RBAC * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * targetType: * type: string * enum: [user, role] * targetUserId: * type: string * targetRoleId: * type: integer * durationHours: * type: number * permissionLevel: * type: string * enum: [view] * responses: * 200: * description: Host shared successfully. * 400: * description: Invalid request body. * 403: * description: Not host owner. * 404: * description: Target user or role not found. * 500: * description: Failed to share host. */ router.post( "/host/:id/share", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const hostId = parseInt(id, 10); const userId = req.userId!; if (isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } try { const { targetType = "user", targetUserId, targetRoleId, durationHours, permissionLevel = "view", } = req.body; if (!["user", "role"].includes(targetType)) { return res .status(400) .json({ error: "Invalid target type. Must be 'user' or 'role'" }); } if (targetType === "user" && !isNonEmptyString(targetUserId)) { return res .status(400) .json({ error: "Target user ID is required when sharing with user" }); } if (targetType === "role" && !targetRoleId) { return res .status(400) .json({ error: "Target role ID is required when sharing with role" }); } const host = await db .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); if (host.length === 0) { databaseLogger.warn("Permission denied", { operation: "rbac_permission_denied", userId, resource: "host", resourceId: hostId, action: "share", }); return res.status(403).json({ error: "Not host owner" }); } if (!host[0].credentialId) { return res.status(400).json({ error: "Only hosts using credentials can be shared. Please create a credential and assign it to this host before sharing.", code: "CREDENTIAL_REQUIRED_FOR_SHARING", }); } if (targetType === "user") { const targetUser = await db .select({ id: users.id, username: users.username }) .from(users) .where(eq(users.id, targetUserId)) .limit(1); if (targetUser.length === 0) { return res.status(404).json({ error: "Target user not found" }); } } else { const targetRole = await db .select({ id: roles.id, name: roles.name }) .from(roles) .where(eq(roles.id, targetRoleId)) .limit(1); if (targetRole.length === 0) { return res.status(404).json({ error: "Target role not found" }); } } let expiresAt: string | null = null; if ( durationHours && typeof durationHours === "number" && durationHours > 0 ) { const expiryDate = new Date(); expiryDate.setHours(expiryDate.getHours() + durationHours); expiresAt = expiryDate.toISOString(); } const validLevels = ["view"]; if (!validLevels.includes(permissionLevel)) { return res.status(400).json({ error: "Invalid permission level. Only 'view' is supported.", validLevels, }); } const whereConditions = [eq(hostAccess.hostId, hostId)]; if (targetType === "user") { whereConditions.push(eq(hostAccess.userId, targetUserId)); } else { whereConditions.push(eq(hostAccess.roleId, targetRoleId)); } const existing = await db .select() .from(hostAccess) .where(and(...whereConditions)) .limit(1); if (existing.length > 0) { await db .update(hostAccess) .set({ permissionLevel, expiresAt, }) .where(eq(hostAccess.id, existing[0].id)); await db .delete(sharedCredentials) .where(eq(sharedCredentials.hostAccessId, existing[0].id)); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( existing[0].id, host[0].credentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( existing[0].id, host[0].credentialId, targetRoleId!, userId, ); } databaseLogger.info("Permission granted", { operation: "rbac_permission_grant", adminId: userId, hostId, resource: "host", action: "view", }); return res.json({ success: true, message: "Host access updated", expiresAt, }); } const result = await db.insert(hostAccess).values({ hostId, userId: targetType === "user" ? targetUserId : null, roleId: targetType === "role" ? targetRoleId : null, grantedBy: userId, permissionLevel, expiresAt, }); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); if (targetType === "user") { await sharedCredManager.createSharedCredentialForUser( result.lastInsertRowid as number, host[0].credentialId, targetUserId!, userId, ); } else { await sharedCredManager.createSharedCredentialsForRole( result.lastInsertRowid as number, host[0].credentialId, targetRoleId!, userId, ); } databaseLogger.success("Host shared successfully", { operation: "rbac_host_share_success", userId, hostId, targetUserId: targetType === "user" ? targetUserId : undefined, permissionLevel, }); res.json({ success: true, message: `Host shared successfully with ${targetType}`, expiresAt, }); } catch (error) { databaseLogger.error("Failed to share host", error, { operation: "share_host", hostId, userId, }); res.status(500).json({ error: "Failed to share host" }); } }, ); /** * @openapi * /rbac/host/{id}/access/{accessId}: * delete: * summary: Revoke host access * description: Revokes a user's or role's access to a host. * tags: * - RBAC * parameters: * - in: path * name: id * required: true * schema: * type: integer * - in: path * name: accessId * required: true * schema: * type: integer * responses: * 200: * description: Access revoked successfully. * 400: * description: Invalid ID. * 403: * description: Not host owner. * 500: * description: Failed to revoke access. */ router.delete( "/host/:id/access/:accessId", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const accessIdParam = Array.isArray(req.params.accessId) ? req.params.accessId[0] : req.params.accessId; const hostId = parseInt(id, 10); const accessId = parseInt(accessIdParam, 10); const userId = req.userId!; if (isNaN(hostId) || isNaN(accessId)) { return res.status(400).json({ error: "Invalid ID" }); } try { const host = await db .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); if (host.length === 0) { return res.status(403).json({ error: "Not host owner" }); } await db.delete(hostAccess).where(eq(hostAccess.id, accessId)); databaseLogger.info("Permission revoked", { operation: "rbac_permission_revoke", adminId: userId, hostId, accessId, }); res.json({ success: true, message: "Access revoked" }); } catch (error) { databaseLogger.error("Failed to revoke host access", error, { operation: "revoke_host_access", hostId, accessId, userId, }); res.status(500).json({ error: "Failed to revoke access" }); } }, ); /** * @openapi * /rbac/host/{id}/access: * get: * summary: Get host access list * description: Retrieves the list of users and roles that have access to a host. * tags: * - RBAC * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The access list for the host. * 400: * description: Invalid host ID. * 403: * description: Not host owner. * 500: * description: Failed to get access list. */ router.get( "/host/:id/access", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const hostId = parseInt(id, 10); const userId = req.userId!; if (isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } try { const host = await db .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); if (host.length === 0) { return res.status(403).json({ error: "Not host owner" }); } const rawAccessList = await db .select({ id: hostAccess.id, userId: hostAccess.userId, roleId: hostAccess.roleId, username: users.username, roleName: roles.name, roleDisplayName: roles.displayName, grantedBy: hostAccess.grantedBy, grantedByUsername: sql`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, createdAt: hostAccess.createdAt, }) .from(hostAccess) .leftJoin(users, eq(hostAccess.userId, users.id)) .leftJoin(roles, eq(hostAccess.roleId, roles.id)) .where(eq(hostAccess.hostId, hostId)) .orderBy(desc(hostAccess.createdAt)); const accessList = rawAccessList.map((access) => ({ id: access.id, targetType: access.userId ? "user" : "role", userId: access.userId, roleId: access.roleId, username: access.username, roleName: access.roleName, roleDisplayName: access.roleDisplayName, grantedBy: access.grantedBy, grantedByUsername: access.grantedByUsername, permissionLevel: access.permissionLevel, expiresAt: access.expiresAt, createdAt: access.createdAt, })); res.json({ accessList }); } catch (error) { databaseLogger.error("Failed to get host access list", error, { operation: "get_host_access_list", hostId, userId, }); res.status(500).json({ error: "Failed to get access list" }); } }, ); /** * @openapi * /rbac/shared-hosts: * get: * summary: Get shared hosts * description: Retrieves the list of hosts that have been shared with the authenticated user. * tags: * - RBAC * responses: * 200: * description: A list of shared hosts. * 500: * description: Failed to get shared hosts. */ router.get( "/shared-hosts", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const userId = req.userId!; try { const now = new Date().toISOString(); const sharedHosts = await db .select({ id: hosts.id, name: hosts.name, ip: hosts.ip, port: hosts.port, username: hosts.username, folder: hosts.folder, tags: hosts.tags, permissionLevel: hostAccess.permissionLevel, expiresAt: hostAccess.expiresAt, grantedBy: hostAccess.grantedBy, ownerUsername: users.username, }) .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .innerJoin(users, eq(hosts.userId, users.id)) .where( and( eq(hostAccess.userId, userId), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .orderBy(desc(hostAccess.createdAt)); res.json({ sharedHosts }); } catch (error) { databaseLogger.error("Failed to get shared hosts", error, { operation: "get_shared_hosts", userId, }); res.status(500).json({ error: "Failed to get shared hosts" }); } }, ); /** * @openapi * /rbac/roles: * get: * summary: Get all roles * description: Retrieves a list of all roles. * tags: * - RBAC * responses: * 200: * description: A list of roles. * 500: * description: Failed to get roles. */ router.get( "/roles", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { try { const allRoles = await db .select() .from(roles) .orderBy(roles.isSystem, roles.name); const rolesWithParsedPermissions = allRoles.map((role) => ({ ...role, permissions: JSON.parse(role.permissions), })); res.json({ roles: rolesWithParsedPermissions }); } catch (error) { databaseLogger.error("Failed to get roles", error, { operation: "get_roles", }); res.status(500).json({ error: "Failed to get roles" }); } }, ); /** * @openapi * /rbac/roles: * get: * summary: Get all roles * description: Retrieves a list of all roles. * tags: * - RBAC * responses: * 200: * description: A list of roles. * 500: * description: Failed to get roles. */ router.get( "/roles", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { try { const rolesList = await db .select({ id: roles.id, name: roles.name, displayName: roles.displayName, description: roles.description, isSystem: roles.isSystem, createdAt: roles.createdAt, updatedAt: roles.updatedAt, }) .from(roles) .orderBy(roles.isSystem, roles.name); res.json({ roles: rolesList }); } catch (error) { databaseLogger.error("Failed to get roles", error, { operation: "get_roles", }); res.status(500).json({ error: "Failed to get roles" }); } }, ); /** * @openapi * /rbac/roles: * post: * summary: Create a new role * description: Creates a new role. * tags: * - RBAC * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * displayName: * type: string * description: * type: string * responses: * 201: * description: Role created successfully. * 400: * description: Invalid request body. * 409: * description: A role with this name already exists. * 500: * description: Failed to create role. */ router.post( "/roles", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { const { name, displayName, description } = req.body; if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) { return res.status(400).json({ error: "Role name and display name are required", }); } if (!/^[a-z0-9_-]+$/.test(name)) { return res.status(400).json({ error: "Role name must contain only lowercase letters, numbers, underscores, and hyphens", }); } try { const existing = await db .select({ id: roles.id }) .from(roles) .where(eq(roles.name, name)) .limit(1); if (existing.length > 0) { return res.status(409).json({ error: "A role with this name already exists", }); } const result = await db.insert(roles).values({ name, displayName, description: description || null, isSystem: false, permissions: null, }); const newRoleId = result.lastInsertRowid; res.status(201).json({ success: true, roleId: newRoleId, message: "Role created successfully", }); } catch (error) { databaseLogger.error("Failed to create role", error, { operation: "create_role", roleName: name, }); res.status(500).json({ error: "Failed to create role" }); } }, ); /** * @openapi * /rbac/roles/{id}: * put: * summary: Update a role * description: Updates a role by its ID. * tags: * - RBAC * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * displayName: * type: string * description: * type: string * responses: * 200: * description: Role updated successfully. * 400: * description: Invalid request body or role ID. * 404: * description: Role not found. * 500: * description: Failed to update role. */ router.put( "/roles/:id", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); const { displayName, description } = req.body; if (isNaN(roleId)) { return res.status(400).json({ error: "Invalid role ID" }); } if (!displayName && description === undefined) { return res.status(400).json({ error: "At least one field (displayName or description) is required", }); } try { const existingRole = await db .select({ id: roles.id, name: roles.name, isSystem: roles.isSystem, }) .from(roles) .where(eq(roles.id, roleId)) .limit(1); if (existingRole.length === 0) { return res.status(404).json({ error: "Role not found" }); } const updates: { displayName?: string; description?: string | null; updatedAt: string; } = { updatedAt: new Date().toISOString(), }; if (displayName) { updates.displayName = displayName; } if (description !== undefined) { updates.description = description || null; } await db.update(roles).set(updates).where(eq(roles.id, roleId)); res.json({ success: true, message: "Role updated successfully", }); } catch (error) { databaseLogger.error("Failed to update role", error, { operation: "update_role", roleId, }); res.status(500).json({ error: "Failed to update role" }); } }, ); /** * @openapi * /rbac/roles/{id}: * delete: * summary: Delete a role * description: Deletes a role by its ID. * tags: * - RBAC * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Role deleted successfully. * 400: * description: Invalid role ID. * 403: * description: Cannot delete system roles. * 404: * description: Role not found. * 500: * description: Failed to delete role. */ router.delete( "/roles/:id", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const roleId = parseInt(id, 10); if (isNaN(roleId)) { return res.status(400).json({ error: "Invalid role ID" }); } try { const role = await db .select({ id: roles.id, name: roles.name, isSystem: roles.isSystem, }) .from(roles) .where(eq(roles.id, roleId)) .limit(1); if (role.length === 0) { return res.status(404).json({ error: "Role not found" }); } if (role[0].isSystem) { return res.status(403).json({ error: "Cannot delete system roles", }); } const deletedUserRoles = await db .delete(userRoles) .where(eq(userRoles.roleId, roleId)) .returning({ userId: userRoles.userId }); for (const { userId } of deletedUserRoles) { permissionManager.invalidateUserPermissionCache(userId); } await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId)); await db.delete(roles).where(eq(roles.id, roleId)); res.json({ success: true, message: "Role deleted successfully", }); } catch (error) { databaseLogger.error("Failed to delete role", error, { operation: "delete_role", roleId, }); res.status(500).json({ error: "Failed to delete role" }); } }, ); /** * @openapi * /rbac/users/{userId}/roles: * post: * summary: Assign a role to a user * description: Assigns a role to a user. * tags: * - RBAC * parameters: * - in: path * name: userId * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * roleId: * type: integer * responses: * 200: * description: Role assigned successfully. * 400: * description: Role ID is required. * 403: * description: System roles cannot be manually assigned. * 404: * description: User or role not found. * 409: * description: Role already assigned. * 500: * description: Failed to assign role. */ router.post( "/users/:userId/roles", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { const targetUserId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; const currentUserId = req.userId!; try { const { roleId } = req.body; if (typeof roleId !== "number") { return res.status(400).json({ error: "Role ID is required" }); } const targetUser = await db .select() .from(users) .where(eq(users.id, targetUserId)) .limit(1); if (targetUser.length === 0) { return res.status(404).json({ error: "User not found" }); } const role = await db .select() .from(roles) .where(eq(roles.id, roleId)) .limit(1); if (role.length === 0) { return res.status(404).json({ error: "Role not found" }); } if (role[0].isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be manually assigned", }); } const existing = await db .select() .from(userRoles) .where( and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), ) .limit(1); if (existing.length > 0) { return res.status(409).json({ error: "Role already assigned" }); } await db.insert(userRoles).values({ userId: targetUserId, roleId, grantedBy: currentUserId, }); const hostsSharedWithRole = await db .select() .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .where(eq(hostAccess.roleId, roleId)); const { SharedCredentialManager } = await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); for (const { host_access, ssh_data } of hostsSharedWithRole) { if (ssh_data.credentialId) { try { await sharedCredManager.createSharedCredentialForUser( host_access.id, ssh_data.credentialId, targetUserId, ssh_data.userId, ); } catch (error) { databaseLogger.error( "Failed to create shared credential for new role member", error, { operation: "assign_role_create_credentials", targetUserId, roleId, hostId: ssh_data.id, }, ); } } } permissionManager.invalidateUserPermissionCache(targetUserId); databaseLogger.info("Role assigned to user", { operation: "rbac_role_assign", adminId: currentUserId, targetUserId, roleId, roleName: role[0].name, }); res.json({ success: true, message: "Role assigned successfully", }); } catch (error) { databaseLogger.error("Failed to assign role", error, { operation: "assign_role", targetUserId, }); res.status(500).json({ error: "Failed to assign role" }); } }, ); /** * @openapi * /rbac/users/{userId}/roles/{roleId}: * delete: * summary: Remove a role from a user * description: Removes a role from a user. * tags: * - RBAC * parameters: * - in: path * name: userId * required: true * schema: * type: string * - in: path * name: roleId * required: true * schema: * type: integer * responses: * 200: * description: Role removed successfully. * 400: * description: Invalid role ID. * 403: * description: System roles cannot be removed. * 404: * description: Role not found. * 500: * description: Failed to remove role. */ router.delete( "/users/:userId/roles/:roleId", authenticateJWT, permissionManager.requireAdmin(), async (req: AuthenticatedRequest, res: Response) => { const targetUserId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; const roleIdParam = Array.isArray(req.params.roleId) ? req.params.roleId[0] : req.params.roleId; const roleId = parseInt(roleIdParam, 10); if (isNaN(roleId)) { return res.status(400).json({ error: "Invalid role ID" }); } try { const role = await db .select({ id: roles.id, name: roles.name, isSystem: roles.isSystem, }) .from(roles) .where(eq(roles.id, roleId)) .limit(1); if (role.length === 0) { return res.status(404).json({ error: "Role not found" }); } if (role[0].isSystem) { return res.status(403).json({ error: "System roles (admin, user) are automatically assigned and cannot be removed", }); } await db .delete(userRoles) .where( and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), ); permissionManager.invalidateUserPermissionCache(targetUserId); databaseLogger.info("Role removed from user", { operation: "rbac_role_remove", adminId: req.userId!, targetUserId, roleId, }); res.json({ success: true, message: "Role removed successfully", }); } catch (error) { databaseLogger.error("Failed to remove role", error, { operation: "remove_role", targetUserId, roleId, }); res.status(500).json({ error: "Failed to remove role" }); } }, ); /** * @openapi * /rbac/users/{userId}/roles: * get: * summary: Get user's roles * description: Retrieves a list of roles for a specific user. * tags: * - RBAC * parameters: * - in: path * name: userId * required: true * schema: * type: string * responses: * 200: * description: A list of roles. * 403: * description: Access denied. * 500: * description: Failed to get user roles. */ router.get( "/users/:userId/roles", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const targetUserId = Array.isArray(req.params.userId) ? req.params.userId[0] : req.params.userId; const currentUserId = req.userId!; if ( targetUserId !== currentUserId && !(await permissionManager.isAdmin(currentUserId)) ) { return res.status(403).json({ error: "Access denied" }); } try { const userRolesList = await db .select({ id: userRoles.id, roleId: roles.id, roleName: roles.name, roleDisplayName: roles.displayName, description: roles.description, isSystem: roles.isSystem, grantedAt: userRoles.grantedAt, }) .from(userRoles) .innerJoin(roles, eq(userRoles.roleId, roles.id)) .where(eq(userRoles.userId, targetUserId)); res.json({ roles: userRolesList }); } catch (error) { databaseLogger.error("Failed to get user roles", error, { operation: "get_user_roles", targetUserId, }); res.status(500).json({ error: "Failed to get user roles" }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/snippets.ts ================================================ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { snippets, snippetFolders } from "../db/schema.js"; import { eq, and, desc, asc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; const router = express.Router(); function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); /** * @openapi * /snippets/folders: * get: * summary: Get all snippet folders * description: Retrieves all snippet folders for the authenticated user. * tags: * - Snippets * responses: * 200: * description: A list of snippet folders. * 400: * description: Invalid userId. * 500: * description: Failed to fetch snippet folders. */ router.get( "/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId for snippet folders fetch"); return res.status(400).json({ error: "Invalid userId" }); } try { const result = await db .select() .from(snippetFolders) .where(eq(snippetFolders.userId, userId)) .orderBy(asc(snippetFolders.name)); res.json(result); } catch (err) { authLogger.error("Failed to fetch snippet folders", err); res.status(500).json({ error: "Failed to fetch snippet folders" }); } }, ); /** * @openapi * /snippets/folders: * post: * summary: Create a new snippet folder * description: Creates a new snippet folder for the authenticated user. * tags: * - Snippets * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * color: * type: string * icon: * type: string * responses: * 201: * description: Snippet folder created successfully. * 400: * description: Folder name is required. * 409: * description: Folder with this name already exists. * 500: * description: Failed to create snippet folder. */ router.post( "/folders", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { name, color, icon } = req.body; if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { authLogger.warn("Invalid snippet folder creation data", { operation: "snippet_folder_create", userId, hasName: !!name, }); return res.status(400).json({ error: "Folder name is required" }); } try { const existing = await db .select() .from(snippetFolders) .where( and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), ); if (existing.length > 0) { return res .status(409) .json({ error: "Folder with this name already exists" }); } const insertData = { userId, name: name.trim(), color: color?.trim() || null, icon: icon?.trim() || null, }; const result = await db .insert(snippetFolders) .values(insertData) .returning(); authLogger.success(`Snippet folder created: ${name} by user ${userId}`, { operation: "snippet_folder_create_success", userId, name, }); res.status(201).json(result[0]); } catch (err) { authLogger.error("Failed to create snippet folder", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to create snippet folder", }); } }, ); /** * @openapi * /snippets/folders/{name}/metadata: * put: * summary: Update snippet folder metadata * description: Updates the metadata (color, icon) of a snippet folder. * tags: * - Snippets * parameters: * - in: path * name: name * required: true * schema: * type: string * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * color: * type: string * icon: * type: string * responses: * 200: * description: Snippet folder metadata updated successfully. * 400: * description: Invalid request. * 404: * description: Folder not found. * 500: * description: Failed to update snippet folder metadata. */ router.put( "/folders/:name/metadata", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name; const { color, icon } = req.body; if (!isNonEmptyString(userId) || !name) { authLogger.warn("Invalid request for snippet folder metadata update"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() .from(snippetFolders) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, decodeURIComponent(name)), ), ); if (existing.length === 0) { return res.status(404).json({ error: "Folder not found" }); } const updateFields: Partial<{ color: string | null; icon: string | null; updatedAt: ReturnType; }> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; if (color !== undefined) updateFields.color = color?.trim() || null; if (icon !== undefined) updateFields.icon = icon?.trim() || null; await db .update(snippetFolders) .set(updateFields) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, decodeURIComponent(name)), ), ); const updated = await db .select() .from(snippetFolders) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, decodeURIComponent(name)), ), ); authLogger.success( `Snippet folder metadata updated: ${name} by user ${userId}`, { operation: "snippet_folder_metadata_update_success", userId, name, }, ); res.json(updated[0]); } catch (err) { authLogger.error("Failed to update snippet folder metadata", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to update snippet folder metadata", }); } }, ); /** * @openapi * /snippets/folders/rename: * put: * summary: Rename a snippet folder * description: Renames a snippet folder for the authenticated user. * tags: * - Snippets * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * oldName: * type: string * newName: * type: string * responses: * 200: * description: Folder renamed successfully. * 400: * description: Invalid request. * 404: * description: Folder not found. * 409: * description: Folder with new name already exists. * 500: * description: Failed to rename snippet folder. */ router.put( "/folders/rename", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { oldName, newName } = req.body; if ( !isNonEmptyString(userId) || !isNonEmptyString(oldName) || !isNonEmptyString(newName) ) { authLogger.warn("Invalid request for snippet folder rename"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() .from(snippetFolders) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, oldName), ), ); if (existing.length === 0) { return res.status(404).json({ error: "Folder not found" }); } const nameExists = await db .select() .from(snippetFolders) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, newName), ), ); if (nameExists.length > 0) { return res .status(409) .json({ error: "Folder with new name already exists" }); } await db .update(snippetFolders) .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, oldName), ), ); await db .update(snippets) .set({ folder: newName }) .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); authLogger.success( `Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`, { operation: "snippet_folder_rename_success", userId, oldName, newName, }, ); res.json({ success: true, oldName, newName }); } catch (err) { authLogger.error("Failed to rename snippet folder", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to rename snippet folder", }); } }, ); /** * @openapi * /snippets/folders/{name}: * delete: * summary: Delete a snippet folder * description: Deletes a snippet folder and moves its snippets to the root. * tags: * - Snippets * parameters: * - in: path * name: name * required: true * schema: * type: string * responses: * 200: * description: Snippet folder deleted successfully. * 400: * description: Invalid request. * 500: * description: Failed to delete snippet folder. */ router.delete( "/folders/:name", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const name = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name; if (!isNonEmptyString(userId) || !name) { authLogger.warn("Invalid request for snippet folder delete"); return res.status(400).json({ error: "Invalid request" }); } try { const folderName = decodeURIComponent(name); await db .update(snippets) .set({ folder: null }) .where( and(eq(snippets.userId, userId), eq(snippets.folder, folderName)), ); await db .delete(snippetFolders) .where( and( eq(snippetFolders.userId, userId), eq(snippetFolders.name, folderName), ), ); authLogger.success( `Snippet folder deleted: ${folderName} by user ${userId}`, { operation: "snippet_folder_delete_success", userId, name: folderName, }, ); res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete snippet folder", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to delete snippet folder", }); } }, ); /** * @openapi * /snippets/reorder: * put: * summary: Reorder snippets * description: Bulk updates the order and folder of snippets. * tags: * - Snippets * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * snippets: * type: array * items: * type: object * properties: * id: * type: integer * order: * type: integer * folder: * type: string * responses: * 200: * description: Snippets reordered successfully. * 400: * description: Invalid request. * 500: * description: Failed to reorder snippets. */ router.put( "/reorder", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { snippets: snippetUpdates } = req.body; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId for snippet reorder"); return res.status(400).json({ error: "Invalid userId" }); } if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) { authLogger.warn("Invalid snippet reorder data", { operation: "snippet_reorder", userId, }); return res .status(400) .json({ error: "snippets array is required and must not be empty" }); } try { for (const update of snippetUpdates) { const { id, order, folder } = update; if (!id || order === undefined) { continue; } const updateFields: Partial<{ order: number; folder: string | null; }> = { order, }; if (folder !== undefined) { updateFields.folder = folder?.trim() || null; } await db .update(snippets) .set(updateFields) .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); } authLogger.success(`Snippets reordered by user ${userId}`, { operation: "snippet_reorder_success", userId, count: snippetUpdates.length, }); res.json({ success: true, updated: snippetUpdates.length }); } catch (err) { authLogger.error("Failed to reorder snippets", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to reorder snippets", }); } }, ); /** * @openapi * /snippets/execute: * post: * summary: Execute a snippet on a host * description: Executes a snippet on a specified host. * tags: * - Snippets * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * snippetId: * type: integer * hostId: * type: integer * responses: * 200: * description: Snippet executed successfully. * 400: * description: Snippet ID and Host ID are required. * 404: * description: Snippet or host not found. * 500: * description: Failed to execute snippet. */ router.post( "/execute", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { snippetId, hostId } = req.body; if (!isNonEmptyString(userId) || !snippetId || !hostId) { authLogger.warn("Invalid snippet execution request", { userId, snippetId, hostId, }); return res .status(400) .json({ error: "Snippet ID and Host ID are required" }); } try { const snippetResult = await db .select() .from(snippets) .where( and( eq(snippets.id, parseInt(snippetId)), eq(snippets.userId, userId), ), ); if (snippetResult.length === 0) { return res.status(404).json({ error: "Snippet not found" }); } const snippet = snippetResult[0]; const { Client } = await import("ssh2"); const { hosts, sshCredentials } = await import("../db/schema.js"); const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); const hostResult = await SimpleDBOps.select( db .select() .from(hosts) .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResult.length === 0) { return res.status(404).json({ error: "Host not found" }); } const host = hostResult[0]; let password = host.password; let privateKey = host.key; let passphrase = host.keyPassword; let authType = host.authType; if (host.credentialId) { const credResult = await SimpleDBOps.select( db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credResult.length > 0) { const cred = credResult[0]; authType = (cred.authType || authType) as string; password = (cred.password || undefined) as string | undefined; privateKey = (cred.privateKey || cred.key || undefined) as | string | undefined; passphrase = (cred.keyPassword || undefined) as string | undefined; } } const conn = new Client(); let output = ""; let errorOutput = ""; const executePromise = new Promise<{ success: boolean; output: string; error?: string; }>((resolve, reject) => { const timeout = setTimeout(() => { conn.end(); reject(new Error("Command execution timeout (30s)")); }, 30000); conn.on("ready", () => { conn.exec(snippet.content, (err, stream) => { if (err) { clearTimeout(timeout); conn.end(); return reject(err); } stream.on("close", () => { clearTimeout(timeout); conn.end(); if (errorOutput) { resolve({ success: false, output, error: errorOutput }); } else { resolve({ success: true, output }); } }); stream.on("data", (data: Buffer) => { output += data.toString(); }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); }); }); conn.on("error", (err) => { clearTimeout(timeout); reject(err); }); const config: Record = { host: host.ip, port: host.port, username: host.username, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 30000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, timeout: 30000, env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", LC_MESSAGES: "en_US.UTF-8", LC_MONETARY: "en_US.UTF-8", LC_NUMERIC: "en_US.UTF-8", LC_TIME: "en_US.UTF-8", LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, algorithms: { kex: [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", ], serverHostKey: [ "ssh-ed25519", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ssh-dss", ], cipher: [ "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes128-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr", "aes256-cbc", "aes192-cbc", "aes128-cbc", "3des-cbc", ], hmac: [ "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], compress: ["none", "zlib@openssh.com", "zlib"], }, }; if (authType === "password" && password) { config.password = password; } else if (authType === "key" && privateKey) { const cleanKey = (privateKey as string) .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); config.privateKey = Buffer.from(cleanKey, "utf8"); if (passphrase) { config.passphrase = passphrase; } } else if (password) { config.password = password; } else if (privateKey) { const cleanKey = (privateKey as string) .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); config.privateKey = Buffer.from(cleanKey, "utf8"); if (passphrase) { config.passphrase = passphrase; } } conn.connect(config); }); const result = await executePromise; authLogger.success( `Snippet executed: ${snippet.name} on host ${hostId}`, { operation: "snippet_execute_success", userId, snippetId, hostId, }, ); res.json(result); } catch (err) { authLogger.error("Failed to execute snippet", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to execute snippet", }); } }, ); /** * @openapi * /snippets: * get: * summary: Get all snippets * description: Retrieves all snippets for the authenticated user. * tags: * - Snippets * responses: * 200: * description: A list of snippets. * 400: * description: Invalid userId. * 500: * description: Failed to fetch snippets. */ router.get( "/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId for snippets fetch"); return res.status(400).json({ error: "Invalid userId" }); } try { const result = await db .select() .from(snippets) .where(eq(snippets.userId, userId)) .orderBy( sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, asc(snippets.folder), asc(snippets.order), desc(snippets.updatedAt), ); res.json(result); } catch (err) { authLogger.error("Failed to fetch snippets", err); res.status(500).json({ error: "Failed to fetch snippets" }); } }, ); /** * @openapi * /snippets/{id}: * get: * summary: Get a specific snippet * description: Retrieves a specific snippet by its ID. * tags: * - Snippets * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The requested snippet. * 400: * description: Invalid request parameters. * 404: * description: Snippet not found. * 500: * description: Failed to fetch snippet. */ router.get( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const snippetId = parseInt(id, 10); if (!isNonEmptyString(userId) || isNaN(snippetId)) { authLogger.warn("Invalid request for snippet fetch: invalid ID", { userId, id, }); return res.status(400).json({ error: "Invalid request parameters" }); } try { const result = await db .select() .from(snippets) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); if (result.length === 0) { return res.status(404).json({ error: "Snippet not found" }); } res.json(result[0]); } catch (err) { authLogger.error("Failed to fetch snippet", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch snippet", }); } }, ); /** * @openapi * /snippets: * post: * summary: Create a new snippet * description: Creates a new snippet for the authenticated user. * tags: * - Snippets * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * content: * type: string * description: * type: string * folder: * type: string * order: * type: integer * responses: * 201: * description: Snippet created successfully. * 400: * description: Name and content are required. * 500: * description: Failed to create snippet. */ router.post( "/", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { name, content, description, folder, order } = req.body; if ( !isNonEmptyString(userId) || !isNonEmptyString(name) || !isNonEmptyString(content) ) { authLogger.warn("Invalid snippet creation data validation failed", { operation: "snippet_create", userId, hasName: !!name, hasContent: !!content, }); return res.status(400).json({ error: "Name and content are required" }); } try { let snippetOrder = order; if (snippetOrder === undefined || snippetOrder === null) { const folderValue = folder?.trim() || ""; const maxOrderResult = await db .select({ maxOrder: sql`MAX(${snippets.order})` }) .from(snippets) .where( and( eq(snippets.userId, userId), folderValue ? eq(snippets.folder, folderValue) : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, ), ); const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; snippetOrder = maxOrder + 1; } const insertData = { userId, name: name.trim(), content: content.trim(), description: description?.trim() || null, folder: folder?.trim() || null, order: snippetOrder, }; const result = await db.insert(snippets).values(insertData).returning(); databaseLogger.info("Command snippet created", { operation: "snippet_create", userId, snippetId: result[0].id, name, }); res.status(201).json(result[0]); } catch (err) { authLogger.error("Failed to create snippet", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to create snippet", }); } }, ); /** * @openapi * /snippets/{id}: * put: * summary: Update a snippet * description: Updates a specific snippet by its ID. * tags: * - Snippets * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * content: * type: string * description: * type: string * folder: * type: string * order: * type: integer * responses: * 200: * description: The updated snippet. * 400: * description: Invalid request. * 404: * description: Snippet not found. * 500: * description: Failed to update snippet. */ router.put( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const updateData = req.body; if (!isNonEmptyString(userId) || !id) { authLogger.warn("Invalid request for snippet update"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() .from(snippets) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); if (existing.length === 0) { return res.status(404).json({ error: "Snippet not found" }); } const updateFields: Partial<{ updatedAt: ReturnType; name: string; content: string; description: string | null; folder: string | null; order: number; }> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; if (updateData.name !== undefined) updateFields.name = updateData.name.trim(); if (updateData.content !== undefined) updateFields.content = updateData.content.trim(); if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; if (updateData.folder !== undefined) updateFields.folder = updateData.folder?.trim() || null; if (updateData.order !== undefined) updateFields.order = updateData.order; await db .update(snippets) .set(updateFields) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); const updated = await db .select() .from(snippets) .where(eq(snippets.id, parseInt(id))); databaseLogger.info("Command snippet updated", { operation: "snippet_update", userId, snippetId: parseInt(id), }); res.json(updated[0]); } catch (err) { authLogger.error("Failed to update snippet", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to update snippet", }); } }, ); /** * @openapi * /snippets/{id}: * delete: * summary: Delete a snippet * description: Deletes a specific snippet by its ID. * tags: * - Snippets * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Snippet deleted successfully. * 400: * description: Invalid request. * 404: * description: Snippet not found. * 500: * description: Failed to delete snippet. */ router.delete( "/:id", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!isNonEmptyString(userId) || !id) { authLogger.warn("Invalid request for snippet delete"); return res.status(400).json({ error: "Invalid request" }); } try { const existing = await db .select() .from(snippets) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); if (existing.length === 0) { return res.status(404).json({ error: "Snippet not found" }); } await db .delete(snippets) .where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId))); databaseLogger.info("Command snippet deleted", { operation: "snippet_delete", userId, snippetId: parseInt(id), }); res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete snippet", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to delete snippet", }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/terminal.ts ================================================ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; import { commandHistory } from "../db/schema.js"; import { eq, and, desc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; const router = express.Router(); function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); /** * @openapi * /terminal/command_history: * post: * summary: Save command to history * description: Saves a command to the command history for a specific host. * tags: * - Terminal * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * command: * type: string * responses: * 201: * description: Command saved successfully. * 400: * description: Missing required parameters. * 500: * description: Failed to save command. */ router.post( "/command_history", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, command } = req.body; if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) { authLogger.warn("Invalid command history save request", { operation: "command_history_save", userId, hasHostId: !!hostId, hasCommand: !!command, }); return res.status(400).json({ error: "Missing required parameters" }); } try { const insertData = { userId, hostId: parseInt(hostId, 10), command: command.trim(), }; const result = await db .insert(commandHistory) .values(insertData) .returning(); res.status(201).json(result[0]); } catch (err) { authLogger.error("Failed to save command to history", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to save command", }); } }, ); /** * @openapi * /terminal/command_history/{hostId}: * get: * summary: Get command history * description: Retrieves the command history for a specific host. * tags: * - Terminal * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: A list of commands. * 400: * description: Invalid request parameters. * 500: * description: Failed to fetch history. */ router.get( "/command_history/:hostId", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId; const hostIdNum = parseInt(hostId, 10); if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { authLogger.warn("Invalid command history fetch request", { userId, hostId: hostIdNum, }); return res.status(400).json({ error: "Invalid request parameters" }); } try { const result = await db .select({ command: commandHistory.command, maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, }) .from(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostIdNum), ), ) .groupBy(commandHistory.command) .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) .limit(500); const uniqueCommands = result.map((r) => r.command); res.json(uniqueCommands); } catch (err) { authLogger.error("Failed to fetch command history", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch history", }); } }, ); /** * @openapi * /terminal/command_history/delete: * post: * summary: Delete a specific command from history * description: Deletes a specific command from the history of a host. * tags: * - Terminal * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * command: * type: string * responses: * 200: * description: Command deleted successfully. * 400: * description: Missing required parameters. * 500: * description: Failed to delete command. */ router.post( "/command_history/delete", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const { hostId, command } = req.body; if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) { authLogger.warn("Invalid command delete request", { operation: "command_history_delete", userId, hasHostId: !!hostId, hasCommand: !!command, }); return res.status(400).json({ error: "Missing required parameters" }); } try { const hostIdNum = parseInt(hostId, 10); await db .delete(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostIdNum), eq(commandHistory.command, command.trim()), ), ); res.json({ success: true }); } catch (err) { authLogger.error("Failed to delete command from history", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to delete command", }); } }, ); /** * @openapi * /terminal/command_history/{hostId}: * delete: * summary: Clear command history * description: Clears the entire command history for a specific host. * tags: * - Terminal * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: Command history cleared successfully. * 400: * description: Invalid request. * 500: * description: Failed to clear history. */ router.delete( "/command_history/:hostId", authenticateJWT, requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = Array.isArray(req.params.hostId) ? req.params.hostId[0] : req.params.hostId; const hostIdNum = parseInt(hostId, 10); if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { authLogger.warn("Invalid command history clear request"); return res.status(400).json({ error: "Invalid request" }); } try { await db .delete(commandHistory) .where( and( eq(commandHistory.userId, userId), eq(commandHistory.hostId, hostIdNum), ), ); databaseLogger.info("Terminal history cleared", { operation: "terminal_history_clear", userId, hostId: hostIdNum, }); res.json({ success: true }); } catch (err) { authLogger.error("Failed to clear command history", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to clear history", }); } }, ); /** * @openapi * /terminal/session_settings: * get: * summary: Get session persistence settings * description: Returns the session timeout and persistence enabled flag. * tags: * - Terminal * responses: * 200: * description: Session settings. * 500: * description: Failed to fetch settings. */ router.get( "/session_settings", authenticateJWT, async (_req: Request, res: Response) => { try { const timeoutRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", ) .get() as { value: string } | undefined; const enabledRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'", ) .get() as { value: string } | undefined; res.json({ timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30, enabled: enabledRow ? enabledRow.value === "true" : true, }); } catch (err) { authLogger.error("Failed to fetch session settings", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch settings", }); } }, ); /** * @openapi * /terminal/session_settings: * post: * summary: Update session persistence settings * description: Saves session timeout and persistence enabled flag. * tags: * - Terminal * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * timeoutMinutes: * type: integer * enabled: * type: boolean * responses: * 200: * description: Settings saved successfully. * 400: * description: Invalid parameters. * 500: * description: Failed to save settings. */ router.post( "/session_settings", authenticateJWT, async (req: Request, res: Response) => { const { timeoutMinutes, enabled } = req.body; if ( timeoutMinutes !== undefined && (typeof timeoutMinutes !== "number" || timeoutMinutes < 1 || timeoutMinutes > 1440) ) { return res .status(400) .json({ error: "timeoutMinutes must be between 1 and 1440" }); } try { if (timeoutMinutes !== undefined) { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)", ) .run(String(timeoutMinutes)); } if (enabled !== undefined) { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)", ) .run(String(enabled)); } res.json({ success: true }); } catch (err) { authLogger.error("Failed to save session settings", err); res.status(500).json({ error: err instanceof Error ? err.message : "Failed to save settings", }); } }, ); export default router; ================================================ FILE: src/backend/database/routes/users.ts ================================================ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { restartGuacServer } from "../../guacamole/guacamole-server.js"; import crypto from "crypto"; import { db } from "../db/index.js"; import { users, sessions, hosts, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, settings, sshCredentialUsage, recentActivity, snippets, snippetFolders, sshFolders, commandHistory, roles, userRoles, hostAccess, sharedCredentials, auditLogs, sessionRecordings, networkTopology, dashboardPreferences, opksshTokens, } from "../db/schema.js"; import { eq } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { nanoid } from "nanoid"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { parseUserAgent, generateDeviceFingerprint, } from "../../utils/user-agent-parser.js"; import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js"; const authManager = AuthManager.getInstance(); function getOIDCConfigFromEnv(): { client_id: string; client_secret: string; issuer_url: string; authorization_url: string; token_url: string; userinfo_url: string; identifier_path: string; name_path: string; scopes: string; allowed_users: string; } | null { const client_id = process.env.OIDC_CLIENT_ID; const client_secret = process.env.OIDC_CLIENT_SECRET; const issuer_url = process.env.OIDC_ISSUER_URL; const authorization_url = process.env.OIDC_AUTHORIZATION_URL; const token_url = process.env.OIDC_TOKEN_URL; if ( !client_id || !client_secret || !issuer_url || !authorization_url || !token_url ) { return null; } return { client_id, client_secret, issuer_url, authorization_url, token_url, userinfo_url: process.env.OIDC_USERINFO_URL || "", identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub", name_path: process.env.OIDC_NAME_PATH || "name", scopes: process.env.OIDC_SCOPES || "openid email profile", allowed_users: process.env.OIDC_ALLOWED_USERS || "", }; } function isOIDCUserAllowed( allowedUsers: string, identifier: string, email?: string, ): boolean { if (!allowedUsers || !allowedUsers.trim()) return true; const patterns = allowedUsers .split(",") .map((p) => p.trim()) .filter(Boolean); if (patterns.length === 0) return true; const values = [ identifier, ...(email && email !== identifier ? [email] : []), ]; for (const pattern of patterns) { if (pattern === "*") return true; for (const value of values) { if (!value) continue; if (pattern.toLowerCase().startsWith("@")) { if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true; } else { if (value.toLowerCase() === pattern.toLowerCase()) return true; } } } return false; } async function verifyOIDCToken( idToken: string, issuerUrl: string, clientId: string, ): Promise> { const normalizedIssuerUrl = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) : issuerUrl; const possibleIssuers = [ issuerUrl, normalizedIssuerUrl, issuerUrl.replace(/\/application\/o\/[^/]+$/, ""), normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""), ]; const jwksUrls = [ `${normalizedIssuerUrl}/.well-known/jwks.json`, `${normalizedIssuerUrl}/jwks/`, `${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`, ]; try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryResponse = await fetch(discoveryUrl); if (discoveryResponse.ok) { const discovery = (await discoveryResponse.json()) as Record< string, unknown >; if (discovery.jwks_uri) { jwksUrls.unshift(discovery.jwks_uri as string); } } } catch (discoveryError) { authLogger.error(`OIDC discovery failed: ${discoveryError}`); } let jwks: Record | null = null; for (const url of jwksUrls) { try { const response = await fetch(url); if (response.ok) { const jwksData = (await response.json()) as Record; if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { jwks = jwksData; break; } else { authLogger.error( `Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`, ); } } else { // expected - non-ok response, try next URL } } catch { continue; } } if (!jwks) { throw new Error("Failed to fetch JWKS from any URL"); } if (!jwks.keys || !Array.isArray(jwks.keys)) { throw new Error( `Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`, ); } const header = JSON.parse( Buffer.from(idToken.split(".")[0], "base64").toString(), ); const keyId = header.kid; const publicKey = jwks.keys.find( (key: Record) => key.kid === keyId, ); if (!publicKey) { throw new Error( `No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record) => k.kid).join(", ")}`, ); } const { importJWK, jwtVerify } = await import("jose"); const key = await importJWK(publicKey); const { payload } = await jwtVerify(idToken, key, { issuer: possibleIssuers, audience: clientId, }); return payload; } const router = express.Router(); function isNonEmptyString(val: unknown): val is string { return typeof val === "string" && val.trim().length > 0; } const authenticateJWT = authManager.createAuthMiddleware(); const requireAdmin = authManager.createAdminMiddleware(); async function deleteUserAndRelatedData(userId: string): Promise { try { await db .delete(sharedCredentials) .where(eq(sharedCredentials.targetUserId, userId)); await db .delete(sessionRecordings) .where(eq(sessionRecordings.userId, userId)); await db.delete(hostAccess).where(eq(hostAccess.userId, userId)); await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId)); await db.delete(sessions).where(eq(sessions.userId, userId)); await db.delete(userRoles).where(eq(userRoles.userId, userId)); await db.delete(auditLogs).where(eq(auditLogs.userId, userId)); await db .delete(sshCredentialUsage) .where(eq(sshCredentialUsage.userId, userId)); await db .delete(fileManagerRecent) .where(eq(fileManagerRecent.userId, userId)); await db .delete(fileManagerPinned) .where(eq(fileManagerPinned.userId, userId)); await db .delete(fileManagerShortcuts) .where(eq(fileManagerShortcuts.userId, userId)); await db.delete(recentActivity).where(eq(recentActivity.userId, userId)); await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId)); await db.delete(snippets).where(eq(snippets.userId, userId)); await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId)); await db.delete(sshFolders).where(eq(sshFolders.userId, userId)); await db.delete(commandHistory).where(eq(commandHistory.userId, userId)); await db.delete(hosts).where(eq(hosts.userId, userId)); await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId)); await db.delete(networkTopology).where(eq(networkTopology.userId, userId)); await db .delete(dashboardPreferences) .where(eq(dashboardPreferences.userId, userId)); await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId)); db.$client .prepare("DELETE FROM settings WHERE key LIKE ?") .run(`user_%_${userId}`); await db.delete(users).where(eq(users.id, userId)); authLogger.success("User and all related data deleted successfully", { operation: "delete_user_and_related_data_complete", userId, }); } catch (error) { authLogger.error("Failed to delete user and related data", error, { operation: "delete_user_and_related_data_failed", userId, }); throw error; } } /** * @openapi * /users/create: * post: * summary: Create a new user * description: Creates a new user with a username and password. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * password: * type: string * responses: * 200: * description: User created successfully. * 400: * description: Username and password are required. * 403: * description: Registration is currently disabled. * 409: * description: Username already exists. * 500: * description: Failed to create user. */ router.post("/create", async (req, res) => { try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") .get(); if (row && (row as Record).value !== "true") { return res .status(403) .json({ error: "Registration is currently disabled" }); } } catch (e) { authLogger.warn("Failed to check registration status", { operation: "registration_check", error: e, }); } const { username, password } = req.body; authLogger.info("User registration attempt", { operation: "user_register_attempt", username, }); if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn( "Invalid user creation attempt - missing username or password", { operation: "user_create", hasUsername: !!username, hasPassword: !!password, }, ); return res .status(400) .json({ error: "Username and password are required" }); } try { const existing = await db .select() .from(users) .where(eq(users.username, username)); if (existing && existing.length > 0) { authLogger.warn("Registration failed - username exists", { operation: "user_register_failed", username, reason: "username_exists", }); return res.status(409).json({ error: "Username already exists" }); } let isFirstUser = false; const countResult = db.$client .prepare("SELECT COUNT(*) as count FROM users") .get(); isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; const saltRounds = parseInt(process.env.SALT || "10", 10); const password_hash = await bcrypt.hash(password, saltRounds); const id = nanoid(); await db.insert(users).values({ id, username, passwordHash: password_hash, isAdmin: isFirstUser, isOidc: false, clientId: "", clientSecret: "", issuerUrl: "", authorizationUrl: "", tokenUrl: "", identifierPath: "", namePath: "", scopes: "openid email profile", totpSecret: null, totpEnabled: false, totpBackupCodes: null, }); try { const defaultRoleName = isFirstUser ? "admin" : "user"; const defaultRole = await db .select({ id: roles.id }) .from(roles) .where(eq(roles.name, defaultRoleName)) .limit(1); if (defaultRole.length > 0) { await db.insert(userRoles).values({ userId: id, roleId: defaultRole[0].id, grantedBy: id, }); } else { authLogger.warn("Default role not found during user registration", { operation: "assign_default_role", userId: id, roleName: defaultRoleName, }); } } catch (roleError) { authLogger.error("Failed to assign default role", roleError, { operation: "assign_default_role", userId: id, }); } try { await authManager.registerUser(id, password); } catch (encryptionError) { await db.delete(users).where(eq(users.id, id)); authLogger.error( "Failed to setup user encryption, user creation rolled back", encryptionError, { operation: "user_create_encryption_failed", userId: id, }, ); return res.status(500).json({ error: "Failed to setup user security - user creation cancelled", }); } try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error("Failed to persist user to disk", saveError, { operation: "user_create_save_failed", userId: id, }); } authLogger.success("User registration successful", { operation: "user_register_success", userId: id, username, isAdmin: isFirstUser, }); res.json({ message: "User created", is_admin: isFirstUser, toast: { type: "success", message: `User created: ${username}` }, }); } catch (err) { authLogger.error("Failed to create user", err); res.status(500).json({ error: "Failed to create user" }); } }); /** * @openapi * /users/oidc-config: * post: * summary: Configure OIDC provider * description: Creates or updates the OIDC provider configuration. * tags: * - Users * responses: * 200: * description: OIDC configuration updated. * 403: * description: Not authorized. * 500: * description: Failed to update OIDC config. */ router.post("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { client_id, client_secret, issuer_url, authorization_url, token_url, userinfo_url, identifier_path, name_path, scopes, allowed_users, } = req.body; const isDisableRequest = (client_id === "" || client_id === null || client_id === undefined) && (client_secret === "" || client_secret === null || client_secret === undefined) && (issuer_url === "" || issuer_url === null || issuer_url === undefined) && (authorization_url === "" || authorization_url === null || authorization_url === undefined) && (token_url === "" || token_url === null || token_url === undefined); const isEnableRequest = isNonEmptyString(client_id) && isNonEmptyString(client_secret) && isNonEmptyString(issuer_url) && isNonEmptyString(authorization_url) && isNonEmptyString(token_url) && isNonEmptyString(identifier_path) && isNonEmptyString(name_path); if (!isDisableRequest && !isEnableRequest) { authLogger.warn( "OIDC validation failed - neither disable nor enable request", { operation: "oidc_config_update", userId, isDisableRequest, isEnableRequest, }, ); return res .status(400) .json({ error: "All OIDC configuration fields are required" }); } if (isDisableRequest) { db.$client .prepare("DELETE FROM settings WHERE key = 'oidc_config'") .run(); authLogger.info("OIDC configuration disabled", { operation: "oidc_disable", userId, }); res.json({ message: "OIDC configuration disabled" }); } else { const config = { client_id, client_secret, issuer_url, authorization_url, token_url, userinfo_url: userinfo_url || "", identifier_path, name_path, scopes: scopes || "openid email profile", allowed_users: allowed_users || "", }; let encryptedConfig; try { const adminDataKey = DataCrypto.getUserDataKey(userId); if (adminDataKey) { const configWithId = { ...config, id: `oidc-config-${userId}` }; encryptedConfig = DataCrypto.encryptRecord( "settings", configWithId, userId, adminDataKey, ); } else { encryptedConfig = { ...config, client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, }; authLogger.warn( "OIDC configuration stored with basic encoding - admin should re-save with password", { operation: "oidc_config_basic_encoding", userId, }, ); } } catch (encryptError) { authLogger.error( "Failed to encrypt OIDC configuration, storing with basic encoding", encryptError, { operation: "oidc_config_encrypt_failed", userId, }, ); encryptedConfig = { ...config, client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`, }; } db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)", ) .run(JSON.stringify(encryptedConfig)); authLogger.info("OIDC configuration updated", { operation: "oidc_update", userId, hasUserinfoUrl: !!userinfo_url, }); res.json({ message: "OIDC configuration updated" }); } } catch (err) { authLogger.error("Failed to update OIDC config", err); res.status(500).json({ error: "Failed to update OIDC config" }); } }); /** * @openapi * /users/oidc-config: * delete: * summary: Disable OIDC configuration * description: Disables the OIDC provider configuration. * tags: * - Users * responses: * 200: * description: OIDC configuration disabled. * 403: * description: Not authorized. * 500: * description: Failed to disable OIDC config. */ router.delete("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } db.$client.prepare("DELETE FROM settings WHERE key = 'oidc_config'").run(); authLogger.success("OIDC configuration disabled", { operation: "oidc_disable", userId, }); res.json({ message: "OIDC configuration disabled" }); } catch (err) { authLogger.error("Failed to disable OIDC config", err); res.status(500).json({ error: "Failed to disable OIDC config" }); } }); /** * @openapi * /users/oidc-config: * get: * summary: Get OIDC configuration * description: Returns the public OIDC configuration. * tags: * - Users * responses: * 200: * description: Public OIDC configuration. * 500: * description: Failed to get OIDC config. */ router.get("/oidc-config", async (req, res) => { try { const envConfig = getOIDCConfigFromEnv(); if (envConfig) { return res.json({ client_id: envConfig.client_id, issuer_url: envConfig.issuer_url, authorization_url: envConfig.authorization_url, scopes: envConfig.scopes, }); } const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") .get(); if (!row) { return res.json(null); } const config = JSON.parse((row as Record).value as string); const publicConfig = { client_id: config.client_id, issuer_url: config.issuer_url, authorization_url: config.authorization_url, scopes: config.scopes, }; return res.json(publicConfig); } catch (err) { authLogger.error("Failed to get OIDC config", err); res.status(500).json({ error: "Failed to get OIDC config" }); } }); /** * @openapi * /users/oidc-config/admin: * get: * summary: Get OIDC configuration for admin * description: Returns the full OIDC configuration for an admin. * tags: * - Users * responses: * 200: * description: Full OIDC configuration. * 500: * description: Failed to get OIDC config for admin. */ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") .get(); if (!row) { return res.json(null); } let config = JSON.parse((row as Record).value as string); if (config.client_secret?.startsWith("encrypted:")) { try { const adminDataKey = DataCrypto.getUserDataKey(userId); if (adminDataKey) { config = DataCrypto.decryptRecord( "settings", config, userId, adminDataKey, ); } else { config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; } } catch { authLogger.warn("Failed to decrypt OIDC config for admin", { operation: "oidc_config_decrypt_failed", userId, }); config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]"; } } else if (config.client_secret?.startsWith("encoded:")) { try { const decoded = Buffer.from( config.client_secret.substring(8), "base64", ).toString("utf8"); config.client_secret = decoded; } catch { authLogger.warn("Failed to decode OIDC config for admin", { operation: "oidc_config_decode_failed", userId, }); config.client_secret = "[ENCODING ERROR]"; } } res.json(config); } catch (err) { authLogger.error("Failed to get OIDC config for admin", err); res.status(500).json({ error: "Failed to get OIDC config for admin" }); } }); /** * @openapi * /users/oidc/authorize: * get: * summary: Get OIDC authorization URL * description: Returns the OIDC authorization URL. * tags: * - Users * parameters: * - in: query * name: rememberMe * schema: * type: boolean * description: Whether to extend the session to 30 days instead of 2 hours. * responses: * 200: * description: OIDC authorization URL. * 404: * description: OIDC not configured. * 500: * description: Failed to generate authorization URL. */ router.get("/oidc/authorize", async (req, res) => { try { const { rememberMe } = req.query; const origin = getRequestOriginWithForceHTTPS(req); const backendCallbackUri = `${origin}/users/oidc/callback`; const envConfig = getOIDCConfigFromEnv(); let config; if (envConfig) { config = envConfig; } else { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") .get(); if (!row) { return res.status(404).json({ error: "OIDC not configured" }); } config = JSON.parse((row as Record).value as string); } const state = nanoid(); const nonce = nanoid(); const referer = req.get("Referer"); let frontendOrigin; if (referer) { const refererUrl = new URL(referer); frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`; } else { frontendOrigin = origin; } db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run(`oidc_state_${state}`, nonce); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run(`oidc_backend_callback_${state}`, backendCallbackUri); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run(`oidc_frontend_origin_${state}`, frontendOrigin); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run( `oidc_remember_me_${state}`, rememberMe === "true" ? "true" : "false", ); const authUrl = new URL(config.authorization_url); authUrl.searchParams.set("client_id", config.client_id); authUrl.searchParams.set("redirect_uri", backendCallbackUri); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", config.scopes); authUrl.searchParams.set("state", state); authUrl.searchParams.set("nonce", nonce); res.json({ auth_url: authUrl.toString(), state, nonce }); } catch (err) { authLogger.error("Failed to generate OIDC auth URL", err); res.status(500).json({ error: "Failed to generate authorization URL" }); } }); /** * @openapi * /users/oidc/callback: * get: * summary: OIDC callback * description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user. * tags: * - Users * responses: * 302: * description: Redirects to the frontend with a success or error message. * 400: * description: Code and state are required. */ router.get("/oidc/callback", async (req, res) => { const { code, state } = req.query; if (!isNonEmptyString(code) || !isNonEmptyString(state)) { return res.status(400).json({ error: "Code and state are required" }); } const storedBackendCallbackRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`oidc_backend_callback_${state}`); const storedFrontendOriginRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`oidc_frontend_origin_${state}`); const storedRememberMeRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`oidc_remember_me_${state}`); if (!storedBackendCallbackRow || !storedFrontendOriginRow) { return res .status(400) .json({ error: "Invalid state parameter - redirect URIs not found" }); } const backendCallbackUri = ( storedBackendCallbackRow as Record ).value as string; const frontendOrigin = (storedFrontendOriginRow as Record) .value as string; const storedRememberMe = (storedRememberMeRow as Record | null)?.value === "true"; try { const storedNonce = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`oidc_state_${state}`); if (!storedNonce) { return res.status(400).json({ error: "Invalid state parameter" }); } const envConfig = getOIDCConfigFromEnv(); let config; if (envConfig) { config = envConfig; } else { const configRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'oidc_config'") .get(); if (!configRow) { return res.status(500).json({ error: "OIDC not configured" }); } config = JSON.parse( (configRow as Record).value as string, ); } const tokenResponse = await fetch(config.token_url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams({ grant_type: "authorization_code", client_id: config.client_id, client_secret: config.client_secret, code: code, redirect_uri: backendCallbackUri, }), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); authLogger.error("OIDC token exchange failed", { operation: "oidc_token_exchange_failed", status: tokenResponse.status, statusText: tokenResponse.statusText, backendCallbackUri, frontendOrigin, errorResponse: errorText, }); return res .status(400) .json({ error: "Failed to exchange authorization code" }); } const tokenData = (await tokenResponse.json()) as Record; db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`oidc_state_${state}`); db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`oidc_backend_callback_${state}`); db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`oidc_frontend_origin_${state}`); db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`oidc_remember_me_${state}`); let userInfo: Record = null; const userInfoUrls: string[] = []; const normalizedIssuerUrl = config.issuer_url.endsWith("/") ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""); try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryResponse = await fetch(discoveryUrl); if (discoveryResponse.ok) { const discovery = (await discoveryResponse.json()) as Record< string, unknown >; if (discovery.userinfo_endpoint) { userInfoUrls.push(discovery.userinfo_endpoint as string); } } } catch (discoveryError) { authLogger.error(`OIDC discovery failed: ${discoveryError}`); } if (config.userinfo_url) { userInfoUrls.unshift(config.userinfo_url); } userInfoUrls.push( `${baseUrl}/userinfo/`, `${baseUrl}/userinfo`, `${normalizedIssuerUrl}/userinfo/`, `${normalizedIssuerUrl}/userinfo`, `${baseUrl}/oauth2/userinfo/`, `${baseUrl}/oauth2/userinfo`, `${normalizedIssuerUrl}/oauth2/userinfo/`, `${normalizedIssuerUrl}/oauth2/userinfo`, ); if (tokenData.id_token) { try { userInfo = await verifyOIDCToken( tokenData.id_token as string, config.issuer_url, config.client_id, ); } catch { try { const parts = (tokenData.id_token as string).split("."); if (parts.length === 3) { const payload = JSON.parse( Buffer.from(parts[1], "base64").toString(), ); userInfo = payload; } } catch (decodeError) { authLogger.error("Failed to decode ID token payload:", decodeError); } } } if (!userInfo && tokenData.access_token) { for (const userInfoUrl of userInfoUrls) { try { const userInfoResponse = await fetch(userInfoUrl, { headers: { Authorization: `Bearer ${tokenData.access_token}`, }, }); if (userInfoResponse.ok) { userInfo = (await userInfoResponse.json()) as Record< string, unknown >; break; } else { authLogger.error( `Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`, ); } } catch (error) { authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); continue; } } } if (!userInfo) { authLogger.error("Failed to get user information from all sources"); authLogger.error(`Tried userinfo URLs: ${userInfoUrls.join(", ")}`); authLogger.error(`Token data keys: ${Object.keys(tokenData).join(", ")}`); authLogger.error(`Has id_token: ${!!tokenData.id_token}`); authLogger.error(`Has access_token: ${!!tokenData.access_token}`); return res.status(400).json({ error: "Failed to get user information" }); } const getNestedValue = ( obj: Record, path: string, ): unknown => { if (!path || !obj) return null; return path.split(".").reduce((current, key) => current?.[key], obj); }; const identifier = (getNestedValue(userInfo, config.identifier_path) || userInfo[config.identifier_path] || userInfo.sub || userInfo.email || userInfo.preferred_username) as string; const name = (getNestedValue(userInfo, config.name_path) || userInfo[config.name_path] || userInfo.name || userInfo.given_name || identifier) as string; if (!identifier) { authLogger.error( `Identifier not found at path: ${config.identifier_path}`, ); authLogger.error(`Available fields: ${Object.keys(userInfo).join(", ")}`); return res.status(400).json({ error: `User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`, }); } const deviceInfo = parseUserAgent(req); let user = await db .select() .from(users) .where(eq(users.oidcIdentifier, identifier)); let isFirstUser = false; if (!user || user.length === 0) { const countResult = db.$client .prepare("SELECT COUNT(*) as count FROM users") .get(); isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; if (!isFirstUser && config.allowed_users) { const email = userInfo.email as string | undefined; if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { authLogger.warn("OIDC user not in allowed list", { operation: "oidc_user_not_allowed", identifier, email, }); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "user_not_allowed"); return res.redirect(redirectUrl.toString()); } } if (!isFirstUser) { try { const regRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'allow_registration'", ) .get(); if (regRow && (regRow as Record).value !== "true") { authLogger.warn( "OIDC user attempted to register when registration is disabled", { operation: "oidc_registration_disabled", identifier, name, }, ); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "registration_disabled"); return res.redirect(redirectUrl.toString()); } } catch (e) { authLogger.warn("Failed to check registration status during OIDC", { operation: "oidc_registration_check", error: e, }); } } const id = nanoid(); await db.insert(users).values({ id, username: name, passwordHash: "", isAdmin: isFirstUser, isOidc: true, oidcIdentifier: identifier, clientId: String(config.client_id), clientSecret: String(config.client_secret), issuerUrl: String(config.issuer_url), authorizationUrl: String(config.authorization_url), tokenUrl: String(config.token_url), identifierPath: String(config.identifier_path), namePath: String(config.name_path), scopes: String(config.scopes), }); try { const defaultRoleName = isFirstUser ? "admin" : "user"; const defaultRole = await db .select({ id: roles.id }) .from(roles) .where(eq(roles.name, defaultRoleName)) .limit(1); if (defaultRole.length > 0) { await db.insert(userRoles).values({ userId: id, roleId: defaultRole[0].id, grantedBy: id, }); } else { authLogger.warn( "Default role not found during OIDC user registration", { operation: "assign_default_role_oidc", userId: id, roleName: defaultRoleName, }, ); } } catch (roleError) { authLogger.error( "Failed to assign default role to OIDC user", roleError, { operation: "assign_default_role_oidc", userId: id, }, ); } try { const sessionDurationMs = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { await db.delete(users).where(eq(users.id, id)); authLogger.error( "Failed to setup OIDC user encryption, user creation rolled back", encryptionError, { operation: "oidc_user_create_encryption_failed", userId: id, }, ); return res.status(500).json({ error: "Failed to setup user security - user creation cancelled", }); } try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error("Failed to persist OIDC user to disk", saveError, { operation: "oidc_user_create_save_failed", userId: id, }); } user = await db.select().from(users).where(eq(users.id, id)); } else { if (config.allowed_users) { const email = userInfo.email as string | undefined; if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) { authLogger.warn("OIDC user not in allowed list (existing user)", { operation: "oidc_user_not_allowed_existing", identifier, email, userId: user[0].id, }); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "user_not_allowed"); return res.redirect(redirectUrl.toString()); } } const isDualAuth = user[0].passwordHash && user[0].passwordHash.trim() !== ""; if (!isDualAuth) { await db .update(users) .set({ username: name }) .where(eq(users.id, user[0].id)); } user = await db.select().from(users).where(eq(users.id, user[0].id)); } const userRecord = user[0]; try { await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { authLogger.error("Failed to setup OIDC user encryption", setupError, { operation: "oidc_user_encryption_setup_failed", userId: userRecord.id, }); } 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 } const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, rememberMe: storedRememberMe, }); authLogger.success("OIDC login successful", { operation: "oidc_login_complete", userId: userRecord.id, username: userRecord.username, }); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 : storedRememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; res.clearCookie("jwt", authManager.getClearCookieOptions(req)); return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); } catch (err) { authLogger.error("OIDC callback failed", err); const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("error", "OIDC authentication failed"); res.redirect(redirectUrl.toString()); } }); /** * @openapi * /users/login: * post: * summary: User login * description: Authenticates a user and returns a JWT. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * password: * type: string * responses: * 200: * description: Login successful. * 400: * description: Invalid username or password. * 401: * description: Invalid username or password. * 403: * description: Password authentication is currently disabled. * 429: * description: Too many login attempts. * 500: * description: Login failed. */ router.post("/login", async (req, res) => { const { username, password, rememberMe } = req.body; const clientIp = req.ip || req.socket.remoteAddress || "unknown"; authLogger.info("User login request received", { operation: "user_login_request", username, }); if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn("Invalid traditional login attempt", { operation: "user_login", hasUsername: !!username, hasPassword: !!password, }); return res.status(400).json({ error: "Invalid username or password" }); } const lockStatus = loginRateLimiter.isLocked(clientIp, username); if (lockStatus.locked) { authLogger.warn("Login attempt blocked due to rate limiting", { operation: "user_login_blocked", username, ip: clientIp, remainingTime: lockStatus.remainingTime, }); return res.status(429).json({ error: "Too many login attempts. Please try again later.", remainingTime: lockStatus.remainingTime, }); } try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") .get(); if (row && (row as { value: string }).value !== "true") { return res .status(403) .json({ error: "Password authentication is currently disabled" }); } } catch (e) { authLogger.error("Failed to check password login status", { operation: "login_check", error: e, }); return res.status(500).json({ error: "Failed to check login status" }); } try { const user = await db .select() .from(users) .where(eq(users.username, username)); if (!user || user.length === 0) { loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, ip: clientIp, remainingAttempts: loginRateLimiter.getRemainingAttempts( clientIp, username, ), }); return res.status(401).json({ error: "Invalid username or password" }); } const userRecord = user[0]; if ( userRecord.isOidc && (!userRecord.passwordHash || userRecord.passwordHash.trim() === "") ) { authLogger.warn("OIDC-only user attempted traditional login", { operation: "user_login", username, userId: userRecord.id, }); return res .status(403) .json({ error: "This user uses external authentication" }); } const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, ip: clientIp, remainingAttempts: loginRateLimiter.getRemainingAttempts( clientIp, username, ), }); return res.status(401).json({ error: "Invalid username or password" }); } try { const kekSalt = await db .select() .from(settings) .where(eq(settings.key, `user_kek_salt_${userRecord.id}`)); if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } } catch { // expected - KEK salt registration may fail for existing users } const deviceInfo = parseUserAgent(req); let dataUnlocked = false; if (userRecord.isOidc) { dataUnlocked = await authManager.authenticateOIDCUser( userRecord.id, deviceInfo.type, ); } else { dataUnlocked = await authManager.authenticateUser( userRecord.id, password, deviceInfo.type, ); } if (!dataUnlocked) { 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, }); } if (userRecord.totpEnabled) { const deviceFingerprint = generateDeviceFingerprint(deviceInfo); const isTrusted = await authManager.isTrustedDevice( userRecord.id, deviceFingerprint, ); if (isTrusted) { authLogger.info("TOTP bypassed for trusted device", { operation: "totp_bypass", userId: userRecord.id, deviceFingerprint, }); } else { const tempToken = await authManager.generateJWTToken(userRecord.id, { pendingTOTP: true, expiresIn: "10m", }); return res.json({ success: true, requires_totp: true, temp_token: tempToken, rememberMe: !!rememberMe, }); } } const token = await authManager.generateJWTToken(userRecord.id, { rememberMe: !!rememberMe, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); loginRateLimiter.resetAttempts(clientIp, username); const payload = await authManager.verifyJWTToken(token); authLogger.success("User login successful", { operation: "user_login_complete", userId: userRecord.id, username, sessionId: payload?.sessionId, }); const response: Record = { success: true, is_admin: !!userRecord.isAdmin, username: userRecord.username, }; const isElectron = req.headers["x-electron-app"] === "true" || req.headers["X-Electron-App"] === "true"; if (isElectron) { response.token = token; } const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .json(response); } catch (err) { authLogger.error("Failed to log in user", err); return res.status(500).json({ error: "Login failed" }); } }); /** * @openapi * /users/logout: * post: * summary: User logout * description: Logs out the user and clears the JWT cookie. * tags: * - Users * responses: * 200: * description: Logged out successfully. * 500: * description: Logout failed. */ router.post("/logout", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; const userId = authReq.userId; if (userId) { const token = req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1]; let sessionId: string | undefined; if (token) { try { const payload = await authManager.verifyJWTToken(token); sessionId = payload?.sessionId; } catch { // expected - token verification may fail during logout } } await authManager.logoutUser(userId, sessionId); authLogger.info("User logged out", { operation: "user_logout", userId, sessionId, }); } return res .clearCookie("jwt", authManager.getClearCookieOptions(req)) .json({ success: true, message: "Logged out successfully" }); } catch (err) { authLogger.error("Logout failed", err); return res.status(500).json({ error: "Logout failed" }); } }); /** * @openapi * /users/me: * get: * summary: Get current user's info * description: Retrieves information about the currently authenticated user. * tags: * - Users * responses: * 200: * description: User information. * 401: * description: Invalid userId or user not found. * 500: * description: Failed to get username. */ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (!isNonEmptyString(userId)) { authLogger.warn("Invalid userId in JWT for /users/me"); return res.status(401).json({ error: "Invalid userId" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { authLogger.warn(`User not found for /users/me: ${userId}`); return res.status(401).json({ error: "User not found" }); } const hasPassword = user[0].passwordHash && user[0].passwordHash.trim() !== ""; const hasOidc = user[0].isOidc && user[0].oidcIdentifier; const isDualAuth = hasPassword && hasOidc; res.json({ userId: user[0].id, username: user[0].username, is_admin: !!user[0].isAdmin, is_oidc: !!user[0].isOidc, is_dual_auth: isDualAuth, totp_enabled: !!user[0].totpEnabled, }); } catch (err) { authLogger.error("Failed to get username", err); res.status(500).json({ error: "Failed to get username" }); } }); /** * @openapi * /users/setup-required: * get: * summary: Check if setup is required * description: Checks if the system requires initial setup (i.e., no users exist). * tags: * - Users * responses: * 200: * description: Setup status. * 500: * description: Failed to check setup status. */ router.get("/setup-required", async (req, res) => { try { const countResult = db.$client .prepare("SELECT COUNT(*) as count FROM users") .get(); const count = (countResult as { count?: number })?.count || 0; res.json({ setup_required: count === 0, }); } catch (err) { authLogger.error("Failed to check setup status", err); res.status(500).json({ error: "Failed to check setup status" }); } }); /** * @openapi * /users/count: * get: * summary: Count users * description: Returns the total number of users in the system. * tags: * - Users * responses: * 200: * description: User count. * 403: * description: Admin access required. * 500: * description: Failed to count users. */ router.get("/count", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user[0] || !user[0].isAdmin) { return res.status(403).json({ error: "Admin access required" }); } const countResult = db.$client .prepare("SELECT COUNT(*) as count FROM users") .get(); const count = (countResult as { count?: number })?.count || 0; res.json({ count }); } catch (err) { authLogger.error("Failed to count users", err); res.status(500).json({ error: "Failed to count users" }); } }); /** * @openapi * /users/db-health: * get: * summary: Database health check * description: Checks if the database is accessible. * tags: * - Users * responses: * 200: * description: Database is accessible. * 500: * description: Database not accessible. */ router.get("/db-health", requireAdmin, async (req, res) => { try { db.$client.prepare("SELECT 1").get(); res.json({ status: "ok" }); } catch (err) { authLogger.error("DB health check failed", err); res.status(500).json({ error: "Database not accessible" }); } }); /** * @openapi * /users/registration-allowed: * get: * summary: Get registration status * description: Checks if user registration is currently allowed. * tags: * - Users * responses: * 200: * description: Registration status. * 500: * description: Failed to get registration allowed status. */ router.get("/registration-allowed", async (req, res) => { try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_registration'") .get(); res.json({ allowed: row ? (row as Record).value === "true" : true, }); } catch (err) { authLogger.error("Failed to get registration allowed", err); res.status(500).json({ error: "Failed to get registration allowed" }); } }); /** * @openapi * /users/registration-allowed: * patch: * summary: Set registration status * description: Enables or disables user registration. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * allowed: * type: boolean * responses: * 200: * description: Registration status updated. * 400: * description: Invalid value for allowed. * 403: * description: Not authorized. * 500: * description: Failed to set registration allowed status. */ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } db.$client .prepare("UPDATE settings SET value = ? WHERE key = 'allow_registration'") .run(allowed ? "true" : "false"); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set registration allowed", err); res.status(500).json({ error: "Failed to set registration allowed" }); } }); /** * @openapi * /users/password-login-allowed: * get: * summary: Get password login status * description: Checks if password-based login is currently allowed. * tags: * - Users * responses: * 200: * description: Password login status. * 500: * description: Failed to get password login allowed status. */ router.get("/password-login-allowed", async (req, res) => { try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") .get(); res.json({ allowed: row ? (row as { value: string }).value === "true" : true, }); } catch (err) { authLogger.error("Failed to get password login allowed", err); res.status(500).json({ error: "Failed to get password login allowed" }); } }); /** * @openapi * /users/password-login-allowed: * patch: * summary: Set password login status * description: Enables or disables password-based login. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * allowed: * type: boolean * responses: * 200: * description: Password login status updated. * 400: * description: Invalid value for allowed. * 403: * description: Not authorized. * 500: * description: Failed to set password login allowed status. */ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", ) .run(allowed ? "true" : "false"); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password login allowed", err); res.status(500).json({ error: "Failed to set password login allowed" }); } }); /** * @openapi * /users/password-reset-allowed: * get: * summary: Get password reset status * description: Checks if password reset is currently allowed. * tags: * - Users * responses: * 200: * description: Password reset status. * 500: * description: Failed to get password reset allowed status. */ router.get("/password-reset-allowed", async (req, res) => { try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") .get(); res.json({ allowed: row ? (row as { value: string }).value === "true" : true, }); } catch (err) { authLogger.error("Failed to get password reset allowed", err); res.status(500).json({ error: "Failed to get password reset allowed" }); } }); /** * @openapi * /users/password-reset-allowed: * patch: * summary: Set password reset status * description: Enables or disables password reset. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * allowed: * type: boolean * responses: * 200: * description: Password reset status updated. * 400: * description: Invalid value for allowed. * 403: * description: Not authorized. * 500: * description: Failed to set password reset allowed status. */ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { allowed } = req.body; if (typeof allowed !== "boolean") { return res.status(400).json({ error: "Invalid value for allowed" }); } db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_reset', ?)", ) .run(allowed ? "true" : "false"); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password reset allowed", err); res.status(500).json({ error: "Failed to set password reset allowed" }); } }); /** * @openapi * /users/delete-account: * delete: * summary: Delete user account * description: Deletes the authenticated user's account. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * password: * type: string * responses: * 200: * description: Account deleted successfully. * 400: * description: Password is required. * 401: * description: Incorrect password. * 403: * description: Cannot delete external authentication accounts or the last admin user. * 404: * description: User not found. * 500: * description: Failed to delete account. */ router.delete("/delete-account", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; if (!isNonEmptyString(password)) { return res .status(400) .json({ error: "Password is required to delete account" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; if (userRecord.isOidc) { return res.status(403).json({ error: "Cannot delete external authentication accounts through this endpoint", }); } const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { authLogger.warn( `Incorrect password provided for account deletion: ${userRecord.username}`, ); return res.status(401).json({ error: "Incorrect password" }); } if (userRecord.isAdmin) { const adminCount = db.$client .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") .get(); if (((adminCount as { count?: number })?.count || 0) <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } await db.delete(users).where(eq(users.id, userId)); authLogger.success(`User account deleted: ${userRecord.username}`); res.json({ message: "Account deleted successfully" }); } catch (err) { authLogger.error("Failed to delete user account", err); res.status(500).json({ error: "Failed to delete account" }); } }); /** * @openapi * /users/initiate-reset: * post: * summary: Initiate password reset * description: Initiates the password reset process for a user. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * responses: * 200: * description: Password reset code has been generated. * 400: * description: Username is required. * 403: * description: Password reset not available for external authentication users. * 404: * description: User not found. * 500: * description: Failed to initiate password reset. */ router.post("/initiate-reset", async (req, res) => { try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'") .get(); if (row && (row as { value: string }).value !== "true") { return res .status(403) .json({ error: "Password reset is currently disabled" }); } } catch (e) { authLogger.warn("Failed to check password reset status", { operation: "password_reset_check", error: e, }); } const { username } = req.body; if (!isNonEmptyString(username)) { return res.status(400).json({ error: "Username is required" }); } try { const user = await db .select() .from(users) .where(eq(users.username, username)); if (!user || user.length === 0) { authLogger.warn( `Password reset attempted for non-existent user: ${username}`, ); return res.status(404).json({ error: "User not found" }); } if (user[0].isOidc) { return res.status(403).json({ error: "Password reset not available for external authentication users", }); } const resetCode = crypto.randomInt(100000, 1000000).toString(); const expiresAt = new Date(Date.now() + 15 * 60 * 1000); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run( `reset_code_${username}`, JSON.stringify({ code: resetCode, expiresAt: expiresAt.toISOString() }), ); authLogger.info( `Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`, ); res.json({ message: "Password reset code has been generated and logged. Check docker logs for the code.", }); } catch (err) { authLogger.error("Failed to initiate password reset", err); res.status(500).json({ error: "Failed to initiate password reset" }); } }); /** * @openapi * /users/verify-reset-code: * post: * summary: Verify reset code * description: Verifies the password reset code. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * resetCode: * type: string * responses: * 200: * description: Reset code verified. * 400: * description: Invalid or expired reset code. * 500: * description: Failed to verify reset code. */ router.post("/verify-reset-code", async (req, res) => { const { username, resetCode } = req.body; if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) { return res .status(400) .json({ error: "Username and reset code are required" }); } try { const lockStatus = loginRateLimiter.isResetCodeLocked(username); if (lockStatus.locked) { authLogger.warn("Reset code verification blocked due to rate limiting", { operation: "reset_code_verify_blocked", username, remainingTime: lockStatus.remainingTime, }); return res.status(429).json({ error: `Rate limited: Too many verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, remainingTime: lockStatus.remainingTime, code: "RESET_CODE_RATE_LIMITED", }); } loginRateLimiter.recordResetCodeAttempt(username); const resetDataRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`reset_code_${username}`); if (!resetDataRow) { authLogger.warn("Reset code verification failed - no code found", { operation: "reset_code_verify_failed", username, remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); return res.status(400).json({ error: "No reset code found for this user", remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); } const resetData = JSON.parse( (resetDataRow as Record).value as string, ); const now = new Date(); const expiresAt = new Date(resetData.expiresAt); if (now > expiresAt) { db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`reset_code_${username}`); authLogger.warn("Reset code verification failed - code expired", { operation: "reset_code_verify_failed", username, remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); return res.status(400).json({ error: "Reset code has expired", remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); } if (resetData.code !== resetCode) { authLogger.warn("Reset code verification failed - invalid code", { operation: "reset_code_verify_failed", username, remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); return res.status(400).json({ error: "Invalid reset code", remainingAttempts: loginRateLimiter.getRemainingResetCodeAttempts(username), }); } loginRateLimiter.resetResetCodeAttempts(username); const tempToken = nanoid(); const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); db.$client .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)") .run( `temp_reset_token_${username}`, JSON.stringify({ token: tempToken, expiresAt: tempTokenExpiry.toISOString(), }), ); res.json({ message: "Reset code verified", tempToken }); } catch (err) { authLogger.error("Failed to verify reset code", err); res.status(500).json({ error: "Failed to verify reset code" }); } }); /** * @openapi * /users/complete-reset: * post: * summary: Complete password reset * description: Completes the password reset process with a new password. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * tempToken: * type: string * newPassword: * type: string * responses: * 200: * description: Password has been successfully reset. * 400: * description: Invalid or expired temporary token. * 404: * description: User not found. * 500: * description: Failed to complete password reset. */ router.post("/complete-reset", async (req, res) => { const { username, tempToken, newPassword } = req.body; if ( !isNonEmptyString(username) || !isNonEmptyString(tempToken) || !isNonEmptyString(newPassword) ) { return res.status(400).json({ error: "Username, temporary token, and new password are required", }); } try { const tempTokenRow = db.$client .prepare("SELECT value FROM settings WHERE key = ?") .get(`temp_reset_token_${username}`); if (!tempTokenRow) { return res.status(400).json({ error: "No temporary token found" }); } const tempTokenData = JSON.parse( (tempTokenRow as Record).value as string, ); const now = new Date(); const expiresAt = new Date(tempTokenData.expiresAt); if (now > expiresAt) { db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`temp_reset_token_${username}`); return res.status(400).json({ error: "Temporary token has expired" }); } if (tempTokenData.token !== tempToken) { return res.status(400).json({ error: "Invalid temporary token" }); } const user = await db .select() .from(users) .where(eq(users.username, username)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userId = user[0].id; const saltRounds = parseInt(process.env.SALT || "10", 10); const password_hash = await bcrypt.hash(newPassword, saltRounds); let userIdFromJwt: string | null = null; const cookie = req.cookies?.jwt; let header: string | undefined; if (req.headers?.authorization?.startsWith("Bearer ")) { header = req.headers?.authorization?.split(" ")[1]; } const token = cookie || header; if (token) { const payload = await authManager.verifyJWTToken(token); if (payload) { userIdFromJwt = payload.userId; } } if (userIdFromJwt === userId) { try { const success = await authManager.resetUserPasswordWithPreservedDEK( userId, newPassword, ); if (!success) { throw new Error("Failed to re-encrypt user data with new password."); } await db .update(users) .set({ passwordHash: password_hash }) .where(eq(users.id, userId)); authManager.logoutUser(userId); authLogger.success( `Password reset (data preserved) for user: ${username}`, { operation: "password_reset_preserved", userId, username, }, ); } catch (encryptionError) { authLogger.error( "Failed to setup user data encryption after password reset", encryptionError, { operation: "password_reset_encryption_failed_preserved", userId, username, }, ); return res.status(500).json({ error: "Password reset failed. Please contact administrator.", }); } } else { await db .update(users) .set({ passwordHash: password_hash }) .where(eq(users.username, username)); try { await db .delete(sshCredentialUsage) .where(eq(sshCredentialUsage.userId, userId)); await db .delete(fileManagerRecent) .where(eq(fileManagerRecent.userId, userId)); await db .delete(fileManagerPinned) .where(eq(fileManagerPinned.userId, userId)); await db .delete(fileManagerShortcuts) .where(eq(fileManagerShortcuts.userId, userId)); await db .delete(recentActivity) .where(eq(recentActivity.userId, userId)); await db .delete(dismissedAlerts) .where(eq(dismissedAlerts.userId, userId)); await db.delete(snippets).where(eq(snippets.userId, userId)); await db.delete(hosts).where(eq(hosts.userId, userId)); await db .delete(sshCredentials) .where(eq(sshCredentials.userId, userId)); await authManager.registerUser(userId, newPassword); authManager.logoutUser(userId); await db .update(users) .set({ totpEnabled: false, totpSecret: null, totpBackupCodes: null, }) .where(eq(users.id, userId)); authLogger.warn( `Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`, { operation: "password_reset_data_deleted", userId, username, }, ); } catch (encryptionError) { authLogger.error( "Failed to setup user data encryption after password reset", encryptionError, { operation: "password_reset_encryption_failed", userId, username, }, ); return res.status(500).json({ error: "Password reset failed. Please contact administrator.", }); } } authLogger.success(`Password successfully reset for user: ${username}`); db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`reset_code_${username}`); db.$client .prepare("DELETE FROM settings WHERE key = ?") .run(`temp_reset_token_${username}`); res.json({ message: "Password has been successfully reset" }); } catch (err) { authLogger.error("Failed to complete password reset", err); res.status(500).json({ error: "Failed to complete password reset" }); } }); /** * @openapi * /users/change-password: * post: * summary: Change user password * description: Changes the authenticated user's password. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * oldPassword: * type: string * newPassword: * type: string * responses: * 200: * description: Password changed successfully. * 400: * description: Old and new passwords are required. * 401: * description: Incorrect current password. * 500: * description: Failed to update password and re-encrypt data. */ router.post("/change-password", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { oldPassword, newPassword } = req.body; authLogger.info("Password change request", { operation: "password_change_request", userId, }); if (!userId) { return res.status(401).json({ error: "User not authenticated" }); } if (!oldPassword || !newPassword) { return res .status(400) .json({ error: "Old and new passwords are required." }); } const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash); if (!isMatch) { authLogger.warn("Password change failed - old password incorrect", { operation: "password_change_failed", userId, reason: "old_password_wrong", }); return res.status(401).json({ error: "Incorrect current password" }); } const success = await authManager.changeUserPassword( userId, oldPassword, newPassword, ); if (!success) { return res .status(500) .json({ error: "Failed to update password and re-encrypt data." }); } const saltRounds = parseInt(process.env.SALT || "10", 10); const password_hash = await bcrypt.hash(newPassword, saltRounds); await db .update(users) .set({ passwordHash: password_hash }) .where(eq(users.id, userId)); authManager.logoutUser(userId); authLogger.success("Password changed successfully", { operation: "password_change_complete", userId, }); res.json({ message: "Password changed successfully. Please log in again." }); }); /** * @openapi * /users/list: * get: * summary: List all users * description: Retrieves a list of all users in the system. * tags: * - Users * responses: * 200: * description: A list of users. * 403: * description: Not authorized. * 500: * description: Failed to list users. */ router.get("/list", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const allUsers = await db .select({ id: users.id, username: users.username, isAdmin: users.isAdmin, isOidc: users.isOidc, passwordHash: users.passwordHash, }) .from(users); res.json({ users: allUsers }); } catch (err) { authLogger.error("Failed to list users", err); res.status(500).json({ error: "Failed to list users" }); } }); /** * @openapi * /users/make-admin: * post: * summary: Make user admin * description: Grants admin privileges to a user. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * responses: * 200: * description: User is now an admin. * 400: * description: Username is required or user is already an admin. * 403: * description: Not authorized. * 404: * description: User not found. * 500: * description: Failed to make user admin. */ router.post("/make-admin", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { username } = req.body; if (!isNonEmptyString(username)) { return res.status(400).json({ error: "Username is required" }); } try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const targetUser = await db .select() .from(users) .where(eq(users.username, username)); if (!targetUser || targetUser.length === 0) { return res.status(404).json({ error: "User not found" }); } if (targetUser[0].isAdmin) { return res.status(400).json({ error: "User is already an admin" }); } await db .update(users) .set({ isAdmin: true }) .where(eq(users.username, username)); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error("Failed to persist admin promotion to disk", saveError, { operation: "make_admin_save_failed", username, }); } authLogger.info("Admin privileges granted", { operation: "admin_grant", adminId: userId, targetUserId: targetUser[0].id, targetUsername: username, }); res.json({ message: `User ${username} is now an admin` }); } catch (err) { authLogger.error("Failed to make user admin", err); res.status(500).json({ error: "Failed to make user admin" }); } }); /** * @openapi * /users/remove-admin: * post: * summary: Remove admin status * description: Revokes admin privileges from a user. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * responses: * 200: * description: Admin status removed from user. * 400: * description: Username is required or cannot remove your own admin status. * 403: * description: Not authorized. * 404: * description: User not found. * 500: * description: Failed to remove admin status. */ router.post("/remove-admin", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { username } = req.body; if (!isNonEmptyString(username)) { return res.status(400).json({ error: "Username is required" }); } try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } if (adminUser[0].username === username) { return res .status(400) .json({ error: "Cannot remove your own admin status" }); } const targetUser = await db .select() .from(users) .where(eq(users.username, username)); if (!targetUser || targetUser.length === 0) { return res.status(404).json({ error: "User not found" }); } if (!targetUser[0].isAdmin) { return res.status(400).json({ error: "User is not an admin" }); } await db .update(users) .set({ isAdmin: false }) .where(eq(users.username, username)); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error("Failed to persist admin removal to disk", saveError, { operation: "remove_admin_save_failed", username, }); } authLogger.info("Admin privileges revoked", { operation: "admin_revoke", adminId: userId, targetUserId: targetUser[0].id, targetUsername: username, }); res.json({ message: `Admin status removed from ${username}` }); } catch (err) { authLogger.error("Failed to remove admin status", err); res.status(500).json({ error: "Failed to remove admin status" }); } }); /** * @openapi * /users/totp/setup: * post: * summary: Setup TOTP * description: Initiates TOTP setup by generating a secret and QR code. * tags: * - Users * responses: * 200: * description: TOTP setup initiated with secret and QR code. * 400: * description: TOTP is already enabled. * 404: * description: User not found. * 500: * description: Failed to setup TOTP. */ router.post("/totp/setup", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } const secret = speakeasy.generateSecret({ name: `Termix (${userRecord.username})`, length: 32, }); await db .update(users) .set({ totpSecret: secret.base32 }) .where(eq(users.id, userId)); const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ""); res.json({ secret: secret.base32, qr_code: qrCodeUrl, }); } catch (err) { authLogger.error("Failed to setup TOTP", err); res.status(500).json({ error: "Failed to setup TOTP" }); } }); /** * @openapi * /users/totp/enable: * post: * summary: Enable TOTP * description: Enables TOTP after verifying the initial code. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * totp_code: * type: string * responses: * 200: * description: TOTP enabled successfully with backup codes. * 400: * description: TOTP code is required or TOTP already enabled. * 401: * description: Invalid TOTP code. * 404: * description: User not found. * 500: * description: Failed to enable TOTP. */ router.post("/totp/enable", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { totp_code } = req.body; if (!totp_code) { return res.status(400).json({ error: "TOTP code is required" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; if (userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is already enabled" }); } if (!userRecord.totpSecret) { return res.status(400).json({ error: "TOTP setup not initiated" }); } const verified = speakeasy.totp.verify({ secret: userRecord.totpSecret, encoding: "base32", token: totp_code, window: 2, }); if (!verified) { return res.status(401).json({ error: "Invalid TOTP code" }); } const backupCodes = Array.from({ length: 8 }, () => Math.random().toString(36).substring(2, 10).toUpperCase(), ); await db .update(users) .set({ totpEnabled: true, totpBackupCodes: JSON.stringify(backupCodes), }) .where(eq(users.id, userId)); authLogger.info("Two-factor authentication enabled", { operation: "totp_enable", userId, }); res.json({ message: "TOTP enabled successfully", backup_codes: backupCodes, }); } catch (err) { authLogger.error("Failed to enable TOTP", err); res.status(500).json({ error: "Failed to enable TOTP" }); } }); /** * @openapi * /users/totp/disable: * post: * summary: Disable TOTP * description: Disables TOTP for a user. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * password: * type: string * totp_code: * type: string * responses: * 200: * description: TOTP disabled successfully. * 400: * description: Password or TOTP code is required. * 401: * description: Incorrect password or invalid TOTP code. * 404: * description: User not found. * 500: * description: Failed to disable TOTP. */ router.post("/totp/disable", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; if (!password && !totp_code) { return res.status(400).json({ error: "Password or TOTP code is required" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); } if (password && !userRecord.isOidc) { const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { return res.status(401).json({ error: "Incorrect password" }); } } else if (totp_code) { const verified = speakeasy.totp.verify({ secret: userRecord.totpSecret!, encoding: "base32", token: totp_code, window: 2, }); if (!verified) { return res.status(401).json({ error: "Invalid TOTP code" }); } } else { return res.status(400).json({ error: "Authentication required" }); } await db .update(users) .set({ totpEnabled: false, totpSecret: null, totpBackupCodes: null, }) .where(eq(users.id, userId)); authLogger.info("Two-factor authentication disabled", { operation: "totp_disable", userId, }); res.json({ message: "TOTP disabled successfully" }); } catch (err) { authLogger.error("Failed to disable TOTP", err); res.status(500).json({ error: "Failed to disable TOTP" }); } }); /** * @openapi * /users/totp/backup-codes: * post: * summary: Generate new backup codes * description: Generates new TOTP backup codes. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * password: * type: string * totp_code: * type: string * responses: * 200: * description: New backup codes generated. * 400: * description: Password or TOTP code is required. * 401: * description: Incorrect password or invalid TOTP code. * 404: * description: User not found. * 500: * description: Failed to generate backup codes. */ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password, totp_code } = req.body; if (!password && !totp_code) { return res.status(400).json({ error: "Password or TOTP code is required" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; if (!userRecord.totpEnabled) { return res.status(400).json({ error: "TOTP is not enabled" }); } if (password && !userRecord.isOidc) { const isMatch = await bcrypt.compare(password, userRecord.passwordHash); if (!isMatch) { return res.status(401).json({ error: "Incorrect password" }); } } else if (totp_code) { const verified = speakeasy.totp.verify({ secret: userRecord.totpSecret!, encoding: "base32", token: totp_code, window: 2, }); if (!verified) { return res.status(401).json({ error: "Invalid TOTP code" }); } } else { return res.status(400).json({ error: "Authentication required" }); } const backupCodes = Array.from({ length: 8 }, () => Math.random().toString(36).substring(2, 10).toUpperCase(), ); await db .update(users) .set({ totpBackupCodes: JSON.stringify(backupCodes) }) .where(eq(users.id, userId)); res.json({ backup_codes: backupCodes }); } catch (err) { authLogger.error("Failed to generate backup codes", err); res.status(500).json({ error: "Failed to generate backup codes" }); } }); /** * @openapi * /users/totp/verify-login: * post: * summary: Verify TOTP during login * description: Verifies the TOTP code during login. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * temp_token: * type: string * totp_code: * type: string * responses: * 200: * description: TOTP verification successful. * 400: * description: Token and TOTP code are required. * 401: * description: Invalid temporary token or TOTP code. * 404: * description: User not found. * 500: * description: TOTP verification failed. */ router.post("/totp/verify-login", async (req, res) => { const { temp_token, totp_code, rememberMe } = req.body; if (!temp_token || !totp_code) { return res.status(400).json({ error: "Token and TOTP code are required" }); } try { const decoded = await authManager.verifyJWTToken(temp_token); if (!decoded || !decoded.pendingTOTP) { return res.status(401).json({ error: "Invalid temporary token" }); } const user = await db .select() .from(users) .where(eq(users.id, decoded.userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id); if (lockStatus.locked) { authLogger.warn("TOTP verification blocked due to rate limiting", { operation: "totp_verify_blocked", userId: userRecord.id, remainingTime: lockStatus.remainingTime, }); return res.status(429).json({ error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`, remainingTime: lockStatus.remainingTime, code: "TOTP_RATE_LIMITED", }); } loginRateLimiter.recordFailedTOTPAttempt(userRecord.id); if (!userRecord.totpEnabled || !userRecord.totpSecret) { return res.status(400).json({ error: "TOTP not enabled for this user" }); } const userDataKey = authManager.getUserDataKey(userRecord.id); if (!userDataKey) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const totpSecret = LazyFieldEncryption.safeGetFieldValue( userRecord.totpSecret, userDataKey, userRecord.id, "totp_secret", ); if (!totpSecret) { await db .update(users) .set({ totpEnabled: false, totpSecret: null, totpBackupCodes: null, }) .where(eq(users.id, userRecord.id)); return res.status(400).json({ error: "TOTP has been disabled due to password reset. Please set up TOTP again.", }); } const verified = speakeasy.totp.verify({ secret: totpSecret, encoding: "base32", token: totp_code, window: 2, }); if (!verified) { let backupCodes = []; try { backupCodes = userRecord.totpBackupCodes ? JSON.parse(userRecord.totpBackupCodes) : []; } catch { backupCodes = []; } if (!Array.isArray(backupCodes)) { backupCodes = []; } const backupIndex = backupCodes.indexOf(totp_code); if (backupIndex === -1) { authLogger.warn("TOTP verification failed - invalid code", { operation: "totp_verify_failed", userId: userRecord.id, remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( userRecord.id, ), }); return res.status(401).json({ error: "Invalid TOTP code", remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts( userRecord.id, ), }); } backupCodes.splice(backupIndex, 1); await db .update(users) .set({ totpBackupCodes: JSON.stringify(backupCodes) }) .where(eq(users.id, userRecord.id)); } loginRateLimiter.resetTOTPAttempts(userRecord.id); const deviceInfo = parseUserAgent(req); if (rememberMe) { const deviceFingerprint = generateDeviceFingerprint(deviceInfo); await authManager.addTrustedDevice( userRecord.id, deviceFingerprint, deviceInfo.type, deviceInfo.deviceInfo, ); authLogger.info("Device automatically trusted via Remember Me", { operation: "totp_auto_trust", userId: userRecord.id, deviceType: deviceInfo.type, }); } const token = await authManager.generateJWTToken(userRecord.id, { rememberMe: !!rememberMe, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); const isElectron = req.headers["x-electron-app"] === "true" || req.headers["X-Electron-App"] === "true"; authLogger.success("TOTP verification successful", { operation: "totp_verify_success", userId: userRecord.id, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); const response: Record = { success: true, is_admin: !!userRecord.isAdmin, username: userRecord.username, userId: userRecord.id, is_oidc: !!userRecord.isOidc, totp_enabled: !!userRecord.totpEnabled, }; if (isElectron) { response.token = token; } const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000; return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .json(response); } catch (err) { authLogger.error("TOTP verification failed", err); return res.status(500).json({ error: "TOTP verification failed" }); } }); /** * @openapi * /users/delete-user: * delete: * summary: Delete user (admin only) * description: Allows an admin to delete another user and all related data. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * username: * type: string * responses: * 200: * description: User deleted successfully. * 400: * description: Username is required or cannot delete yourself. * 403: * description: Not authorized or cannot delete last admin. * 404: * description: User not found. * 500: * description: Failed to delete user. */ router.delete("/delete-user", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { username } = req.body; if (!isNonEmptyString(username)) { return res.status(400).json({ error: "Username is required" }); } try { const adminUser = await db.select().from(users).where(eq(users.id, userId)); if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } if (adminUser[0].username === username) { return res.status(400).json({ error: "Cannot delete your own account" }); } const targetUser = await db .select() .from(users) .where(eq(users.username, username)); if (!targetUser || targetUser.length === 0) { return res.status(404).json({ error: "User not found" }); } if (targetUser[0].isAdmin) { const adminCount = db.$client .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") .get(); if (((adminCount as { count?: number })?.count || 0) <= 1) { return res .status(403) .json({ error: "Cannot delete the last admin user" }); } } const targetUserId = targetUser[0].id; await deleteUserAndRelatedData(targetUserId); authLogger.warn("User account deleted by admin", { operation: "admin_delete_user", adminId: userId, targetUserId, targetUsername: username, }); res.json({ message: `User ${username} deleted successfully` }); } catch (err) { authLogger.error("Failed to delete user", err); if (err && typeof err === "object" && "code" in err) { if (err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { res.status(400).json({ error: "Cannot delete user: User has associated data that cannot be removed", }); } else { res.status(500).json({ error: `Database error: ${err.code}` }); } } else { res.status(500).json({ error: "Failed to delete account" }); } } }); /** * @openapi * /users/unlock-data: * post: * summary: Unlock user data * description: Re-authenticates user with password to unlock encrypted data. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * password: * type: string * responses: * 200: * description: Data unlocked successfully. * 400: * description: Password is required. * 401: * description: Invalid password. * 500: * description: Failed to unlock data. */ router.post("/unlock-data", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; if (!password) { return res.status(400).json({ error: "Password is required" }); } try { const unlocked = await authManager.authenticateUser(userId, password); if (unlocked) { res.json({ success: true, message: "Data unlocked successfully", }); } else { authLogger.warn("Failed to unlock user data - invalid password", { operation: "user_data_unlock_failed", userId, }); res.status(401).json({ error: "Invalid password" }); } } catch (err) { authLogger.error("Data unlock failed", err, { operation: "user_data_unlock_error", userId, }); res.status(500).json({ error: "Failed to unlock data" }); } }); /** * @openapi * /users/data-status: * get: * summary: Check user data unlock status * description: Checks if user data is currently unlocked. * tags: * - Users * responses: * 200: * description: Data status returned. * 500: * description: Failed to check data status. */ router.get("/data-status", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { res.json({ unlocked: true, message: "Data is unlocked", }); } catch (err) { authLogger.error("Failed to check data status", err, { operation: "data_status_check_failed", userId, }); res.status(500).json({ error: "Failed to check data status" }); } }); /** * @openapi * /users/sessions: * get: * summary: Get sessions * description: Retrieves all sessions for authenticated user (or all sessions for admins). * tags: * - Users * responses: * 200: * description: Sessions list returned. * 404: * description: User not found. * 500: * description: Failed to get sessions. */ router.get("/sessions", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; let sessionList; if (userRecord.isAdmin) { sessionList = await authManager.getAllSessions(); const enrichedSessions = await Promise.all( sessionList.map(async (session) => { const sessionUser = await db .select({ username: users.username }) .from(users) .where(eq(users.id, session.userId)) .limit(1); return { ...session, username: sessionUser[0]?.username || "Unknown", }; }), ); return res.json({ sessions: enrichedSessions }); } else { sessionList = await authManager.getUserSessions(userId); return res.json({ sessions: sessionList }); } } catch (err) { authLogger.error("Failed to get sessions", err); res.status(500).json({ error: "Failed to get sessions" }); } }); /** * @openapi * /users/sessions/{sessionId}: * delete: * summary: Revoke a specific session * description: Revokes a specific session by ID. * tags: * - Users * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * description: The session ID to revoke * responses: * 200: * description: Session revoked successfully. * 400: * description: Session ID is required. * 403: * description: Not authorized to revoke this session. * 404: * description: Session not found. * 500: * description: Failed to revoke session. */ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const sessionId = Array.isArray(req.params.sessionId) ? req.params.sessionId[0] : req.params.sessionId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; const sessionRecords = await db .select() .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); if (sessionRecords.length === 0) { return res.status(404).json({ error: "Session not found" }); } const session = sessionRecords[0]; if (!userRecord.isAdmin && session.userId !== userId) { return res .status(403) .json({ error: "Not authorized to revoke this session" }); } const success = await authManager.revokeSession(sessionId); if (success) { authLogger.success("Session revoked", { operation: "session_revoke", sessionId, revokedBy: userId, sessionUserId: session.userId, }); res.json({ success: true, message: "Session revoked successfully" }); } else { res.status(500).json({ error: "Failed to revoke session" }); } } catch (err) { authLogger.error("Failed to revoke session", err); res.status(500).json({ error: "Failed to revoke session" }); } }); /** * @openapi * /users/sessions/revoke-all: * post: * summary: Revoke all sessions for a user * description: Revokes all sessions with option to exclude current session. * tags: * - Users * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * targetUserId: * type: string * exceptCurrent: * type: boolean * responses: * 200: * description: Sessions revoked successfully. * 403: * description: Not authorized to revoke sessions for other users. * 404: * description: User not found. * 500: * description: Failed to revoke sessions. */ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { targetUserId, exceptCurrent } = req.body; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0) { return res.status(404).json({ error: "User not found" }); } const userRecord = user[0]; let revokeUserId = userId; if (targetUserId && userRecord.isAdmin) { revokeUserId = targetUserId; } else if (targetUserId && targetUserId !== userId) { return res.status(403).json({ error: "Not authorized to revoke sessions for other users", }); } let currentSessionId: string | undefined; if (exceptCurrent) { const token = req.cookies?.jwt || req.headers?.authorization?.split(" ")[1]; if (token) { const payload = await authManager.verifyJWTToken(token); currentSessionId = payload?.sessionId; } } const revokedCount = await authManager.revokeAllUserSessions( revokeUserId, currentSessionId, ); authLogger.success("User sessions revoked", { operation: "user_sessions_revoke_all", revokeUserId, revokedBy: userId, exceptCurrent, revokedCount, }); res.json({ message: `${revokedCount} session(s) revoked successfully`, count: revokedCount, }); } catch (err) { authLogger.error("Failed to revoke user sessions", err); res.status(500).json({ error: "Failed to revoke sessions" }); } }); /** * @openapi * /users/link-oidc-to-password: * post: * summary: Link OIDC user to password account * description: Merges an OIDC-only account into a password-based account (admin only). * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * oidcUserId: * type: string * targetUsername: * type: string * responses: * 200: * description: Accounts linked successfully. * 400: * description: Invalid request or incompatible accounts. * 403: * description: Admin access required. * 404: * description: User not found. * 500: * description: Failed to link accounts. */ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { const adminUserId = (req as AuthenticatedRequest).userId; const { oidcUserId, targetUsername } = req.body; if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { return res.status(400).json({ error: "OIDC user ID and target username are required", }); } try { const adminUser = await db .select() .from(users) .where(eq(users.id, adminUserId)); if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { return res.status(403).json({ error: "Admin access required" }); } const oidcUserRecords = await db .select() .from(users) .where(eq(users.id, oidcUserId)); if (!oidcUserRecords || oidcUserRecords.length === 0) { return res.status(404).json({ error: "OIDC user not found" }); } const oidcUser = oidcUserRecords[0]; if (!oidcUser.isOidc) { return res.status(400).json({ error: "Source user is not an OIDC user", }); } const targetUserRecords = await db .select() .from(users) .where(eq(users.username, targetUsername)); if (!targetUserRecords || targetUserRecords.length === 0) { return res.status(404).json({ error: "Target password user not found" }); } const targetUser = targetUserRecords[0]; if (targetUser.isOidc || !targetUser.passwordHash) { return res.status(400).json({ error: "Target user must be a password-based account", }); } if (targetUser.clientId && targetUser.oidcIdentifier) { return res.status(400).json({ error: "Target user already has OIDC authentication configured", }); } authLogger.info("Linking OIDC user to password account", { operation: "link_oidc_to_password", oidcUserId, oidcUsername: oidcUser.username, targetUserId: targetUser.id, targetUsername: targetUser.username, adminUserId, }); await db .update(users) .set({ isOidc: true, oidcIdentifier: oidcUser.oidcIdentifier, clientId: oidcUser.clientId, clientSecret: oidcUser.clientSecret, issuerUrl: oidcUser.issuerUrl, authorizationUrl: oidcUser.authorizationUrl, tokenUrl: oidcUser.tokenUrl, identifierPath: oidcUser.identifierPath, namePath: oidcUser.namePath, scopes: oidcUser.scopes || "openid email profile", }) .where(eq(users.id, targetUser.id)); try { await authManager.convertToOIDCEncryption(targetUser.id); } catch (encryptionError) { authLogger.error( "Failed to convert encryption to OIDC during linking", encryptionError, { operation: "link_convert_encryption_failed", userId: targetUser.id, }, ); await db .update(users) .set({ isOidc: false, oidcIdentifier: null, clientId: "", clientSecret: "", issuerUrl: "", authorizationUrl: "", tokenUrl: "", identifierPath: "", namePath: "", scopes: "openid email profile", }) .where(eq(users.id, targetUser.id)); return res.status(500).json({ error: "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", details: encryptionError instanceof Error ? encryptionError.message : "Unknown error", }); } await authManager.revokeAllUserSessions(oidcUserId); authManager.logoutUser(oidcUserId); await deleteUserAndRelatedData(oidcUserId); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error("Failed to persist account linking to disk", saveError, { operation: "link_oidc_save_failed", oidcUserId, targetUserId: targetUser.id, }); } authLogger.success( `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, { operation: "link_oidc_to_password_success", oidcUserId, oidcUsername: oidcUser.username, targetUserId: targetUser.id, targetUsername: targetUser.username, adminUserId, }, ); res.json({ success: true, message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`, }); } catch (err) { authLogger.error("Failed to link OIDC user to password account", err, { operation: "link_oidc_to_password_failed", oidcUserId, targetUsername, adminUserId, }); res.status(500).json({ error: "Failed to link accounts", details: err instanceof Error ? err.message : "Unknown error", }); } }); /** * @openapi * /users/unlink-oidc-from-password: * post: * summary: Unlink OIDC from password account * description: Removes OIDC authentication from a dual-auth account (admin only). * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * userId: * type: string * responses: * 200: * description: OIDC unlinked successfully. * 400: * description: Invalid request or user doesn't have OIDC. * 403: * description: Admin privileges required. * 404: * description: User not found. * 500: * description: Failed to unlink OIDC. */ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { const adminUserId = (req as AuthenticatedRequest).userId; const { userId } = req.body; if (!userId) { return res.status(400).json({ error: "User ID is required", }); } try { const adminUser = await db .select() .from(users) .where(eq(users.id, adminUserId)); if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) { authLogger.warn("Non-admin attempted to unlink OIDC from password", { operation: "unlink_oidc_unauthorized", adminUserId, targetUserId: userId, }); return res.status(403).json({ error: "Admin privileges required", }); } const targetUserRecords = await db .select() .from(users) .where(eq(users.id, userId)); if (!targetUserRecords || targetUserRecords.length === 0) { return res.status(404).json({ error: "User not found", }); } const targetUser = targetUserRecords[0]; if (!targetUser.isOidc) { return res.status(400).json({ error: "User does not have OIDC authentication enabled", }); } if (!targetUser.passwordHash || targetUser.passwordHash === "") { return res.status(400).json({ error: "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", }); } authLogger.info("Unlinking OIDC from password account", { operation: "unlink_oidc_from_password_start", targetUserId: targetUser.id, targetUsername: targetUser.username, adminUserId, }); await db .update(users) .set({ isOidc: false, oidcIdentifier: null, clientId: "", clientSecret: "", issuerUrl: "", authorizationUrl: "", tokenUrl: "", identifierPath: "", namePath: "", scopes: "openid email profile", }) .where(eq(users.id, targetUser.id)); try { const { saveMemoryDatabaseToFile } = await import("../db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { authLogger.error( "Failed to save database after unlinking OIDC", saveError, { operation: "unlink_oidc_save_failed", targetUserId: targetUser.id, }, ); } authLogger.success("OIDC unlinked from password account successfully", { operation: "unlink_oidc_from_password_success", targetUserId: targetUser.id, targetUsername: targetUser.username, adminUserId, }); res.json({ success: true, message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, }); } catch (err) { authLogger.error("Failed to unlink OIDC from password account", err, { operation: "unlink_oidc_from_password_failed", targetUserId: userId, adminUserId, }); res.status(500).json({ error: "Failed to unlink OIDC", details: err instanceof Error ? err.message : "Unknown error", }); } }); /** * @openapi * /users/guacamole-settings: * get: * summary: Get Guacamole settings * description: Returns current guacd enabled status and host:port URL. No authentication required. * tags: * - Users * responses: * 200: * description: Guacamole settings. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * url: * type: string * 500: * description: Failed to get guacamole settings. */ router.get("/guacamole-settings", async (req, res) => { try { const enabledRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .get() as { value: string } | undefined; const urlRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get() as { value: string } | undefined; res.json({ enabled: enabledRow ? enabledRow.value !== "false" : true, url: urlRow ? urlRow.value : "guacd:4822", }); } catch (err) { authLogger.error("Failed to get guacamole settings", err); res.status(500).json({ error: "Failed to get guacamole settings" }); } }); /** * @openapi * /users/guacamole-settings: * patch: * summary: Update Guacamole settings * description: Admin-only. Updates guacd enabled status and/or host:port URL. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * url: * type: string * responses: * 200: * description: Guacamole settings updated. * 403: * description: Not authorized. * 500: * description: Failed to update guacamole settings. */ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const user = await db.select().from(users).where(eq(users.id, userId)); if (!user || user.length === 0 || !user[0].isAdmin) { return res.status(403).json({ error: "Not authorized" }); } const { enabled, url } = req.body; if (typeof enabled === "boolean") { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)", ) .run(enabled ? "true" : "false"); } if (typeof url === "string") { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)", ) .run(url); try { await restartGuacServer(); } catch (err) { authLogger.error("Failed to restart guac server after URL update", err); } } const enabledRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .get() as { value: string } | undefined; const urlRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get() as { value: string } | undefined; res.json({ enabled: enabledRow ? enabledRow.value !== "false" : true, url: urlRow ? urlRow.value : "guacd:4822", }); } catch (err) { authLogger.error("Failed to update guacamole settings", err); res.status(500).json({ error: "Failed to update guacamole settings" }); } }); export default router; ================================================ FILE: src/backend/guacamole/guacamole-server.ts ================================================ import GuacamoleLite from "guacamole-lite"; import { parse as parseUrl } from "url"; import { guacLogger } from "../utils/logger.js"; import { AuthManager } from "../utils/auth-manager.js"; import { GuacamoleTokenService } from "./token-service.js"; import { getDb } from "../database/db/index.js"; import type { IncomingMessage } from "http"; const authManager = AuthManager.getInstance(); 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); 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; } } catch { // DB not available yet, use env var defaults } return { host, port }; } const GUAC_WS_PORT = 30008; const websocketOptions = { port: GUAC_WS_PORT, }; const clientOptions = { crypt: { cypher: "AES-256-CBC", key: tokenService.getEncryptionKey(), }, log: { level: "ERRORS", stdLog: (...args: unknown[]) => { guacLogger.info(args.join(" ")); }, errorLog: (...args: unknown[]) => { guacLogger.error(args.join(" ")); }, }, allowedUnencryptedConnectionSettings: { rdp: ["width", "height", "dpi"], vnc: ["width", "height", "dpi"], telnet: ["width", "height"], }, connectionDefaultSettings: { rdp: { security: "any", "ignore-cert": true, "enable-wallpaper": false, "enable-font-smoothing": true, "enable-desktop-composition": false, "disable-audio": false, "enable-drive": false, "resize-method": "display-update", width: 1280, height: 720, dpi: 96, }, vnc: { "swap-red-blue": false, cursor: "remote", width: 1280, height: 720, }, telnet: { "terminal-type": "xterm-256color", }, }, }; const _origConsoleLog = console.log; console.log = (...args: unknown[]) => { const msg = args[0]; if (typeof msg === "string" && msg.startsWith("New client connection")) return; _origConsoleLog(...args); }; function createGuacServer(): GuacamoleLite { const guacdOptions = readGuacdOptions(); const server = new GuacamoleLite( websocketOptions, guacdOptions, clientOptions, ); server.on( "open", (clientConnection: { connectionSettings?: Record }) => { guacLogger.info("Guacamole connection opened", { operation: "guac_connection_open", type: clientConnection.connectionSettings?.type, }); }, ); server.on( "close", (clientConnection: { connectionSettings?: Record }) => { guacLogger.info("Guacamole connection closed", { operation: "guac_connection_close", type: clientConnection.connectionSettings?.type, }); }, ); server.on( "error", ( clientConnection: { connectionSettings?: Record }, error: Error, ) => { guacLogger.error("Guacamole connection error", error, { operation: "guac_connection_error", type: clientConnection.connectionSettings?.type, }); }, ); return server; } let guacServer = createGuacServer(); export async function restartGuacServer(): Promise { try { guacServer.close(); } catch (err) { guacLogger.error("Error closing guac server during restart", err as Error); } guacServer = createGuacServer(); } export { guacServer, tokenService }; ================================================ FILE: src/backend/guacamole/routes.ts ================================================ import express from "express"; import { GuacamoleTokenService } from "./token-service.js"; import { guacLogger } from "../utils/logger.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { getDb } from "../database/db/index.js"; import { hosts } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import type { AuthenticatedRequest } from "../../types/index.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); const authManager = AuthManager.getInstance(); router.use(authManager.createAuthMiddleware()); /** * POST /guacamole/token * Generate an encrypted connection token for guacamole-lite * * Body: { * type: "rdp" | "vnc" | "telnet", * hostname: string, * port?: number, * username?: string, * password?: string, * domain?: string, * // Additional protocol-specific options * } */ router.post("/token", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; const { type, hostname, port, username, password, domain, ...options } = req.body; if (!type || !hostname) { return res .status(400) .json({ error: "Missing required fields: type and hostname" }); } if (!["rdp", "vnc", "telnet"].includes(type)) { return res.status(400).json({ error: "Invalid connection type. Must be rdp, vnc, or telnet", }); } let token: string; switch (type) { case "rdp": token = tokenService.createRdpToken( hostname, username || "", password || "", { port: port || 3389, domain, ...options, }, ); break; case "vnc": token = tokenService.createVncToken(hostname, password, { port: port || 5900, ...options, }); break; case "telnet": token = tokenService.createTelnetToken(hostname, username, password, { port: port || 23, ...options, }); break; default: return res.status(400).json({ error: "Invalid connection type" }); } res.json({ token }); } catch (error) { guacLogger.error("Failed to generate guacamole token", error, { operation: "guac_token_error", }); res.status(500).json({ error: "Failed to generate connection token" }); } }); /** * @openapi * /guacamole/connect-host/{hostId}: * post: * summary: Generate Guacamole connection token from host configuration * description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet * tags: * - Guacamole * security: * - bearerAuth: [] * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * description: Host ID to connect to * responses: * 200: * description: Connection token generated successfully * content: * application/json: * schema: * type: object * properties: * token: * type: string * description: Encrypted connection token * 400: * description: Invalid request or unsupported connection type * 403: * description: Access denied to host * 404: * description: Host not found * 500: * description: Server error */ router.post( "/connect-host/:hostId", async (req: express.Request, res: express.Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const hostId = parseInt(req.params.hostId, 10); if (!hostId || isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } const hostResults = await SimpleDBOps.select( getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); if (hostResults.length === 0) { return res.status(404).json({ error: "Host not found" }); } const host = hostResults[0]; if (host.userId !== userId) { const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( userId, hostId, "read", ); if (!accessInfo.hasAccess) { guacLogger.warn("User attempted to access host without permission", { operation: "guac_access_denied", userId, hostId, }); return res.status(403).json({ error: "Access denied to this host" }); } } const connectionType = (host.connectionType as string) || "ssh"; if (!["rdp", "vnc", "telnet"].includes(connectionType)) { return res.status(400).json({ error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`, }); } let guacConfig: Record = {}; if (host.guacamoleConfig) { try { guacConfig = typeof host.guacamoleConfig === "string" ? JSON.parse(host.guacamoleConfig as string) : (host.guacamoleConfig as Record); } catch (error) { guacLogger.warn("Failed to parse guacamole config", { operation: "guac_config_parse_error", hostId, error: error instanceof Error ? error.message : "Unknown error", }); } } let token: string; const hostname = host.ip as string; const port = host.port as number; const username = (host.username as string) || ""; const password = (host.password as string) || ""; const domain = (host.domain as string) || ""; switch (connectionType) { case "rdp": token = tokenService.createRdpToken(hostname, username, password, { port: port || 3389, domain, security: (host.security as string) || undefined, "ignore-cert": (host.ignoreCert as boolean) || false, ...guacConfig, }); break; case "vnc": token = tokenService.createVncToken(hostname, password, { port: port || 5900, ...guacConfig, }); break; case "telnet": token = tokenService.createTelnetToken(hostname, username, password, { port: port || 23, ...guacConfig, }); break; default: return res.status(400).json({ error: "Invalid connection type" }); } res.json({ token }); } catch (error) { guacLogger.error("Failed to generate guacamole token for host", error, { operation: "guac_host_token_error", }); res.status(500).json({ error: "Failed to generate connection token" }); } }, ); /** * GET /guacamole/status * Check if guacd is reachable */ router.get("/status", async (req, res) => { try { let guacdHost = process.env.GUACD_HOST || "localhost"; let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10); 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); } } catch { // Fall back to env vars } const net = await import("net"); const checkConnection = (): Promise => { return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(3000); socket.on("connect", () => { socket.destroy(); resolve(true); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.connect(guacdPort, guacdHost); }); }; const isConnected = await checkConnection(); res.json({ guacd: { host: guacdHost, port: guacdPort, status: isConnected ? "connected" : "disconnected", }, websocket: { port: 30008, status: "running", }, }); } catch (error) { guacLogger.error("Failed to check guacamole status", error, { operation: "guac_status_error", }); res.status(500).json({ error: "Failed to check status" }); } }); export default router; ================================================ FILE: src/backend/guacamole/token-service.ts ================================================ import crypto from "crypto"; import { guacLogger } from "../utils/logger.js"; export interface GuacamoleConnectionSettings { type: "rdp" | "vnc" | "telnet"; settings: { hostname: string; port?: number; username?: string; password?: string; domain?: string; width?: number; height?: number; dpi?: number; security?: string; "ignore-cert"?: boolean; "enable-wallpaper"?: boolean; "enable-drive"?: boolean; "drive-path"?: string; "create-drive-path"?: boolean; "swap-red-blue"?: boolean; cursor?: string; "terminal-type"?: string; [key: string]: unknown; }; } export interface GuacamoleToken { connection: GuacamoleConnectionSettings; } const CIPHER = "aes-256-cbc"; const KEY_LENGTH = 32; export class GuacamoleTokenService { private static instance: GuacamoleTokenService; private encryptionKey: Buffer; private constructor() { this.encryptionKey = this.initializeKey(); } static getInstance(): GuacamoleTokenService { if (!GuacamoleTokenService.instance) { GuacamoleTokenService.instance = new GuacamoleTokenService(); } return GuacamoleTokenService.instance; } private initializeKey(): Buffer { const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY; if (existingKey) { if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) { return Buffer.from(existingKey, "hex"); } if (existingKey.length === KEY_LENGTH) { return Buffer.from(existingKey, "utf8"); } } const jwtSecret = process.env.JWT_SECRET; if (jwtSecret) { return crypto .createHash("sha256") .update(jwtSecret + "_guacamole") .digest(); } guacLogger.warn( "No persistent encryption key found, generating random key", { operation: "guac_key_generation", }, ); return crypto.randomBytes(KEY_LENGTH); } getEncryptionKey(): Buffer { return this.encryptionKey; } encryptToken(tokenObject: GuacamoleToken): string { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv); let encrypted = cipher.update( JSON.stringify(tokenObject), "utf8", "base64", ); encrypted += cipher.final("base64"); const data = { iv: iv.toString("base64"), value: encrypted, }; return Buffer.from(JSON.stringify(data)).toString("base64"); } decryptToken(token: string): GuacamoleToken | null { try { const data = JSON.parse(Buffer.from(token, "base64").toString("utf8")); const iv = Buffer.from(data.iv, "base64"); const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv); let decrypted = decipher.update(data.value, "base64", "utf8"); decrypted += decipher.final("utf8"); return JSON.parse(decrypted) as GuacamoleToken; } catch (error) { guacLogger.error("Failed to decrypt guacamole token", error, { operation: "guac_token_decrypt_error", }); return null; } } createRdpToken( hostname: string, username: string, password: string, options: Partial = {}, ): string { const token: GuacamoleToken = { connection: { type: "rdp", settings: { hostname, username, password, port: 3389, security: "nla", "ignore-cert": true, ...options, }, }, }; return this.encryptToken(token); } createVncToken( hostname: string, password?: string, options: Partial = {}, ): string { const token: GuacamoleToken = { connection: { type: "vnc", settings: { hostname, password, port: 5900, ...options, }, }, }; return this.encryptToken(token); } createTelnetToken( hostname: string, username?: string, password?: string, options: Partial = {}, ): string { const token: GuacamoleToken = { connection: { type: "telnet", settings: { hostname, username, password, port: 23, ...options, }, }, }; return this.encryptToken(token); } } ================================================ FILE: src/backend/scripts/enable-ssl.sh ================================================ #!/bin/bash set -e RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" ENV_FILE="$PROJECT_ROOT/.env" log_info() { echo -e "${BLUE}[SSL Setup]${NC} $1" } log_success() { echo -e "${GREEN}[SSL Setup]${NC} $1" } log_warn() { echo -e "${YELLOW}[SSL Setup]${NC} $1" } log_error() { echo -e "${RED}[SSL Setup]${NC} $1" } log_header() { echo -e "${CYAN}$1${NC}" } generate_keys() { log_info "Generating security keys..." JWT_SECRET=$(openssl rand -hex 32) log_success "Generated JWT secret" DATABASE_KEY=$(openssl rand -hex 32) log_success "Generated database encryption key" echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE" echo "DATABASE_KEY=$DATABASE_KEY" >> "$ENV_FILE" log_success "Security keys added to .env file" } setup_env_file() { log_info "Setting up environment configuration..." if [[ -f "$ENV_FILE" ]]; then log_warn ".env file already exists, creating backup..." cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%s)" fi cat > "$ENV_FILE" << EOF # Termix SSL Configuration - Auto-generated $(date) # SSL/TLS Configuration ENABLE_SSL=true SSL_PORT=8443 SSL_DOMAIN=localhost PORT=8080 # Node environment NODE_ENV=production # CORS configuration ALLOWED_ORIGINS=* EOF generate_keys log_success "Environment configuration created at $ENV_FILE" } setup_ssl_certificates() { log_info "Setting up SSL certificates..." if [[ -f "$SCRIPT_DIR/setup-ssl.sh" ]]; then bash "$SCRIPT_DIR/setup-ssl.sh" else log_error "SSL setup script not found at $SCRIPT_DIR/setup-ssl.sh" exit 1 fi } main() { if ! command -v openssl &> /dev/null; then log_error "OpenSSL is not installed. Please install OpenSSL first." exit 1 fi setup_env_file setup_ssl_certificates } main "$@" ================================================ FILE: src/backend/scripts/setup-ssl.sh ================================================ #!/bin/bash set -e SSL_DIR="$(dirname "$0")/../ssl" CERT_FILE="$SSL_DIR/termix.crt" KEY_FILE="$SSL_DIR/termix.key" DAYS_VALID=365 DOMAIN=${SSL_DOMAIN:-"localhost"} ALT_NAMES=${SSL_ALT_NAMES:-"DNS:localhost,DNS:127.0.0.1,DNS:*.localhost,IP:127.0.0.1"} RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log_info() { echo -e "${BLUE}[SSL Setup]${NC} $1" } log_success() { echo -e "${GREEN}[SSL Setup]${NC} $1" } log_warn() { echo -e "${YELLOW}[SSL Setup]${NC} $1" } log_error() { echo -e "${RED}[SSL Setup]${NC} $1" } check_existing_cert() { if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then if openssl x509 -in "$CERT_FILE" -checkend 2592000 -noout 2>/dev/null; then log_success "Valid SSL certificate already exists" local expiry=$(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | cut -d= -f2) log_info "Expires: $expiry" return 0 else log_warn "Existing certificate is expired or expiring soon" fi fi return 1 } generate_certificate() { log_info "Generating new SSL certificate for domain: $DOMAIN" mkdir -p "$SSL_DIR" local config_file="$SSL_DIR/openssl.conf" cat > "$config_file" << EOF [req] default_bits = 2048 prompt = no default_md = sha256 distinguished_name = dn req_extensions = v3_req [dn] C=US ST=State L=City O=Termix OU=IT Department CN=$DOMAIN [v3_req] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = localhost DNS.2 = 127.0.0.1 DNS.3 = *.localhost IP.1 = 127.0.0.1 EOF if [[ -n "$SSL_ALT_NAMES" ]]; then local counter=2 IFS=',' read -ra NAMES <<< "$SSL_ALT_NAMES" for name in "${NAMES[@]}"; do name=$(echo "$name" | xargs) if [[ "$name" == DNS:* ]]; then echo "DNS.$((counter++)) = ${name#DNS:}" >> "$config_file" elif [[ "$name" == IP:* ]]; then echo "IP.$((counter++)) = ${name#IP:}" >> "$config_file" fi done fi log_info "Generating private key..." openssl genrsa -out "$KEY_FILE" 2048 log_info "Generating certificate..." openssl req -new -x509 -key "$KEY_FILE" -out "$CERT_FILE" -days $DAYS_VALID -config "$config_file" -extensions v3_req chmod 600 "$KEY_FILE" chmod 644 "$CERT_FILE" rm -f "$config_file" log_success "SSL certificate generated successfully" log_info "Valid for: $DAYS_VALID days" } main() { if ! command -v openssl &> /dev/null; then log_error "OpenSSL is not installed. Please install OpenSSL first." exit 1 fi generate_certificate } main "$@" ================================================ FILE: src/backend/ssh/auth-manager.ts ================================================ import type { WebSocket } from "ws"; import { sshLogger, authLogger } from "../utils/logger.js"; import { getDb } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; interface ResolvedCredentials { username: string; password?: string; key?: Buffer; keyPassword?: string; authType?: string; } interface HostConfig { id: number; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; } interface AuthContext { userId: string; ws: WebSocket; hostId: number; isKeyboardInteractive: boolean; keyboardInteractiveResponded: boolean; keyboardInteractiveFinish: ((responses: string[]) => void) | null; totpPromptSent: boolean; warpgateAuthPromptSent: boolean; totpTimeout: NodeJS.Timeout | null; warpgateAuthTimeout: NodeJS.Timeout | null; totpAttempts: number; } export class SSHAuthManager { public context: AuthContext; constructor(context: AuthContext) { this.context = context; } async resolveCredentials( hostConfig: HostConfig, ): Promise { let resolvedCredentials: ResolvedCredentials = { username: hostConfig.username, authType: hostConfig.authType || "none", }; if (hostConfig.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, hostConfig.credentialId), eq(sshCredentials.userId, this.context.userId), ), ), "ssh_credentials", this.context.userId, ); if (credentials.length > 0) { const cred = credentials[0]; resolvedCredentials = { username: (cred.username as string) || hostConfig.username, password: (cred.password as string) || undefined, key: cred.privateKey ? Buffer.from(cred.privateKey as string) : undefined, keyPassword: (cred.keyPassword as string) || undefined, authType: (cred.authType as string) || "none", }; } } else { if (hostConfig.password) { resolvedCredentials.password = hostConfig.password; resolvedCredentials.authType = "password"; } if (hostConfig.key) { resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8"); resolvedCredentials.authType = "key"; if (hostConfig.keyPassword) { resolvedCredentials.keyPassword = hostConfig.keyPassword; } } } return resolvedCredentials; } handleKeyboardInteractive( name: string, instructions: string, instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, resolvedCredentials: ResolvedCredentials, ): void { this.context.isKeyboardInteractive = true; const promptTexts = prompts.map((p) => p.prompt); const warpgatePattern = /warpgate\s+authentication/i; const isWarpgate = warpgatePattern.test(name) || warpgatePattern.test(instructions) || promptTexts.some((p) => warpgatePattern.test(p)); if (isWarpgate) { this.handleWarpgateAuth(name, instructions, promptTexts, finish); return; } const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); if (totpPromptIndex !== -1) { this.handleTotpAuth( prompts, totpPromptIndex, finish, resolvedCredentials, ); return; } this.handlePasswordAuth(prompts, finish, resolvedCredentials); } private handleWarpgateAuth( name: string, instructions: string, promptTexts: string[], finish: (responses: string[]) => void, ): void { const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); const keyMatch = fullText.match( /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, ); if (urlMatch) { this.context.keyboardInteractiveFinish = () => { finish([""]); }; this.context.warpgateAuthPromptSent = true; this.sendLog("auth", "info", "Warpgate authentication required"); this.context.ws.send( JSON.stringify({ type: "warpgate_auth_required", url: urlMatch[0], securityKey: keyMatch ? keyMatch[1] : "N/A", instructions: instructions, }), ); this.context.warpgateAuthTimeout = setTimeout(() => { if (this.context.keyboardInteractiveFinish) { this.context.keyboardInteractiveFinish = null; this.context.warpgateAuthPromptSent = false; sshLogger.warn("Warpgate authentication timeout", { operation: "warpgate_timeout", hostId: this.context.hostId, }); this.context.ws.send( JSON.stringify({ type: "error", message: "Warpgate authentication timeout. Please reconnect.", }), ); } }, 300000); } } private handleTotpAuth( prompts: Array<{ prompt: string; echo: boolean }>, totpPromptIndex: number, finish: (responses: string[]) => void, resolvedCredentials: ResolvedCredentials, ): void { if (this.context.totpPromptSent) { sshLogger.warn("TOTP prompt asked again - invalid code", { operation: "ssh_keyboard_interactive_totp_retry", hostId: this.context.hostId, }); authLogger.warn("TOTP verification failed for SSH session", { operation: "terminal_totp_failed", userId: this.context.userId, hostId: this.context.hostId, }); this.sendLog("auth", "warning", "Invalid TOTP code"); this.context.ws.send( JSON.stringify({ type: "totp_retry", }), ); return; } this.context.totpPromptSent = true; this.context.keyboardInteractiveResponded = true; this.context.keyboardInteractiveFinish = (totpResponses: string[]) => { const totpCode = (totpResponses[0] || "").trim(); const responses = prompts.map((p, index) => { if (index === totpPromptIndex) { return totpCode; } if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); }; if (this.context.totpTimeout) { clearTimeout(this.context.totpTimeout); } this.context.totpTimeout = setTimeout(() => { if (this.context.keyboardInteractiveFinish) { this.context.keyboardInteractiveFinish = null; this.context.totpPromptSent = false; sshLogger.warn("TOTP prompt timeout", { operation: "totp_timeout", hostId: this.context.hostId, }); this.context.ws.send( JSON.stringify({ type: "error", message: "TOTP verification timeout. Please reconnect.", }), ); } }, 180000); this.sendLog("auth", "info", "TOTP verification required"); authLogger.info("TOTP verification prompt sent to client", { operation: "terminal_totp_prompt", userId: this.context.userId, hostId: this.context.hostId, }); this.context.ws.send( JSON.stringify({ type: "totp_required", prompt: prompts[totpPromptIndex].prompt, }), ); } private handlePasswordAuth( prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, resolvedCredentials: ResolvedCredentials, ): void { const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none"; const passwordPromptIndex = prompts.findIndex((p) => /password/i.test(p.prompt), ); if (!hasStoredPassword && passwordPromptIndex !== -1) { if (this.context.keyboardInteractiveResponded) { return; } this.context.keyboardInteractiveResponded = true; this.context.keyboardInteractiveFinish = (userResponses: string[]) => { const userInput = (userResponses[0] || "").trim(); const responses = prompts.map((p, index) => { if (index === passwordPromptIndex) { return userInput; } return ""; }); finish(responses); }; if (this.context.totpTimeout) { clearTimeout(this.context.totpTimeout); } this.context.totpTimeout = setTimeout(() => { if (this.context.keyboardInteractiveFinish) { this.context.keyboardInteractiveFinish = null; this.context.keyboardInteractiveResponded = false; sshLogger.warn("Password prompt timeout", { operation: "password_timeout", hostId: this.context.hostId, }); this.context.ws.send( JSON.stringify({ type: "error", message: "Password verification timeout. Please reconnect.", }), ); } }, 180000); this.sendLog("auth", "info", "Password authentication required"); this.context.ws.send( JSON.stringify({ type: "password_required", prompt: prompts[passwordPromptIndex].prompt, }), ); return; } const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); } sendLog( stage: string, level: string, message: string, details?: Record, ): void { this.context.ws.send( JSON.stringify({ type: "connection_log", data: { stage, level, message, details, }, }), ); } cleanup(): void { if (this.context.totpTimeout) { clearTimeout(this.context.totpTimeout); this.context.totpTimeout = null; } if (this.context.warpgateAuthTimeout) { clearTimeout(this.context.warpgateAuthTimeout); this.context.warpgateAuthTimeout = null; } this.context.keyboardInteractiveFinish = null; this.context.totpPromptSent = false; this.context.warpgateAuthPromptSent = false; this.context.keyboardInteractiveResponded = false; } } ================================================ FILE: src/backend/ssh/docker-console.ts ================================================ import { Client as SSHClient } from "ssh2"; import { WebSocketServer, WebSocket } from "ws"; import { parse as parseUrl } from "url"; import { AuthManager } from "../utils/auth-manager.js"; import { hosts, sshCredentials } from "../database/db/schema.js"; import { and, eq } from "drizzle-orm"; 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"; const sshLogger = systemLogger; interface SSHSession { client: SSHClient; stream: import("ssh2").ClientChannel | null; isConnected: boolean; containerId?: string; shell?: string; hostId?: number; } const activeSessions = new Map(); const wss = new WebSocketServer({ host: "0.0.0.0", port: 30009, verifyClient: async (info) => { try { const url = parseUrl(info.req.url || "", true); const token = url.query.token as string; if (!token) { return false; } const authManager = AuthManager.getInstance(); const decoded = await authManager.verifyJWTToken(token); if (!decoded || !decoded.userId) { return false; } return true; } catch { return false; } }, }); async function detectShell( session: SSHSession, containerId: string, ): Promise { const shells = ["bash", "sh", "ash"]; for (const shell of shells) { try { await new Promise((resolve, reject) => { session.client.exec( `docker exec ${containerId} which ${shell}`, (err, stream) => { if (err) return reject(err); let output = ""; stream.on("data", (data: Buffer) => { output += data.toString(); }); stream.on("close", (code: number) => { if (code === 0 && output.trim()) { resolve(); } else { reject(new Error(`Shell ${shell} not found`)); } }); stream.stderr.on("data", () => { // Ignore stderr }); }, ); }); return shell; } catch { continue; } } return "sh"; } async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } let currentClient: SSHClient | null = null; for (let i = 0; i < jumpHosts.length; i++) { const jumpHostId = jumpHosts[i].hostId; const jumpHostData = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (jumpHostData.length === 0) { throw new Error(`Jump host ${jumpHostId} not found`); } const jumpHost = jumpHostData[0] as unknown as SSHHost; if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) { try { jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts); } catch (e) { sshLogger.error("Failed to parse jump hosts", e, { hostId: jumpHost.id, }); jumpHost.jumpHosts = []; } } let resolvedCredentials: { password?: string; sshKey?: string; keyPassword?: string; authType?: string; } = { password: jumpHost.password, sshKey: jumpHost.key, keyPassword: jumpHost.keyPassword, authType: jumpHost.authType, }; if (jumpHost.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, jumpHost.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; } } const client = new SSHClient(); const config: Record = { host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip, port: jumpHost.port || 22, username: jumpHost.username, tryKeyboard: true, readyTimeout: 60000, keepaliveInterval: 30000, keepaliveCountMax: 120, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, }; if ( resolvedCredentials.authType === "password" && resolvedCredentials.password ) { config.password = resolvedCredentials.password; } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey ) { const cleanKey = resolvedCredentials.sshKey .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); config.privateKey = Buffer.from(cleanKey, "utf8"); if (resolvedCredentials.keyPassword) { config.passphrase = resolvedCredentials.keyPassword; } } if (currentClient) { await new Promise((resolve, reject) => { currentClient!.forwardOut( "127.0.0.1", 0, jumpHost.ip, jumpHost.port || 22, (err, stream) => { if (err) return reject(err); config.sock = stream; resolve(); }, ); }); } await new Promise((resolve, reject) => { client.on("ready", () => resolve()); client.on("error", reject); client.connect(config); }); currentClient = client; } return currentClient; } wss.on("connection", async (ws: WebSocket, req) => { const userId = (req as unknown as { userId: string }).userId; const sessionId = `docker-console-${Date.now()}-${Math.random()}`; sshLogger.info("Docker console WebSocket connected", { operation: "docker_console_connect", sessionId, userId, }); let sshSession: SSHSession | null = null; const wsPingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.ping(); } }, 30000); ws.on("message", async (data) => { try { const message = JSON.parse(data.toString()); switch (message.type) { case "connect": { const { hostConfig, containerId, shell, cols, rows } = message.data as { hostConfig: SSHHost; containerId: string; shell?: string; cols?: number; rows?: number; }; if ( typeof hostConfig.jumpHosts === "string" && hostConfig.jumpHosts ) { try { hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts); } catch (e) { sshLogger.error("Failed to parse jump hosts", e, { hostId: hostConfig.id, }); hostConfig.jumpHosts = []; } } if (!hostConfig || !containerId) { ws.send( JSON.stringify({ type: "error", message: "Host configuration and container ID are required", }), ); return; } if (!hostConfig.enableDocker) { ws.send( JSON.stringify({ type: "error", message: "Docker is not enabled for this host. Enable it in Host Settings.", }), ); return; } try { let resolvedCredentials: { password?: string; sshKey?: string; keyPassword?: string; authType?: string; } = { password: hostConfig.password, sshKey: hostConfig.key, keyPassword: hostConfig.keyPassword, authType: hostConfig.authType, }; if (hostConfig.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, hostConfig.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; } } const client = new SSHClient(); const config: Record = { host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip, port: hostConfig.port || 22, username: hostConfig.username, tryKeyboard: true, readyTimeout: 60000, keepaliveInterval: 30000, keepaliveCountMax: 120, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, }; if ( resolvedCredentials.authType === "password" && resolvedCredentials.password ) { config.password = resolvedCredentials.password; } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey ) { const cleanKey = resolvedCredentials.sshKey .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); config.privateKey = Buffer.from(cleanKey, "utf8"); if (resolvedCredentials.keyPassword) { config.passphrase = resolvedCredentials.keyPassword; } } if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) { const jumpClient = await createJumpHostChain( hostConfig.jumpHosts, userId, ); if (jumpClient) { const stream = await new Promise( (resolve, reject) => { jumpClient.forwardOut( "127.0.0.1", 0, hostConfig.ip, hostConfig.port || 22, (err, stream) => { if (err) return reject(err); resolve(stream); }, ); }, ); config.sock = stream; } } await new Promise((resolve, reject) => { client.on("ready", () => resolve()); client.on("error", reject); client.connect(config); }); sshSession = { client, stream: null, isConnected: true, containerId, hostId: hostConfig.id, }; activeSessions.set(sessionId, sshSession); let shellToUse = shell || "bash"; if (shell) { try { await new Promise((resolve, reject) => { client.exec( `docker exec ${containerId} which ${shell}`, (err, stream) => { if (err) return reject(err); let output = ""; stream.on("data", (data: Buffer) => { output += data.toString(); }); stream.on("close", (code: number) => { if (code === 0 && output.trim()) { resolve(); } else { reject(new Error(`Shell ${shell} not available`)); } }); stream.stderr.on("data", () => { // Ignore stderr }); }, ); }); } catch { sshLogger.warn( `Requested shell ${shell} not found, detecting available shell`, { operation: "shell_validation", sessionId, containerId, requestedShell: shell, }, ); shellToUse = await detectShell(sshSession, containerId); } } else { shellToUse = await detectShell(sshSession, containerId); } sshSession.shell = shellToUse; const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`; sshLogger.info("Attaching to Docker container", { operation: "docker_attach", sessionId, userId, hostId: hostConfig.id, containerId, }); client.exec( execCommand, { pty: { term: "xterm-256color", cols: cols || 80, rows: rows || 24, }, }, (err, stream) => { if (err) { sshLogger.error("Failed to create docker exec", err, { operation: "docker_exec", sessionId, containerId, }); ws.send( JSON.stringify({ type: "error", message: `Failed to start console: ${err.message}`, }), ); return; } sshSession!.stream = stream; sshLogger.success("Docker container attached", { operation: "docker_attach_success", sessionId, userId, hostId: hostConfig.id, containerId, }); stream.on("data", (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "output", data: data.toString("utf8"), }), ); } }); stream.stderr.on("data", () => { // stderr output ignored }); stream.on("close", () => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "disconnected", message: "Console session ended", }), ); } if (sshSession) { sshSession.client.end(); activeSessions.delete(sessionId); } }); ws.send( JSON.stringify({ type: "connected", data: { shell: shellToUse, requestedShell: shell, shellChanged: shell && shell !== shellToUse, }, }), ); }, ); } catch (error) { sshLogger.error("Failed to connect to container", error, { operation: "console_connect", sessionId, containerId: message.data.containerId, }); ws.send( JSON.stringify({ type: "error", message: error instanceof Error ? error.message : "Failed to connect to container", }), ); } break; } case "input": { if (sshSession && sshSession.stream) { sshSession.stream.write(message.data); } break; } case "resize": { if (sshSession && sshSession.stream) { const { cols, rows } = message.data; sshSession.stream.setWindow(rows, cols, rows, cols); } break; } case "disconnect": { if (sshSession) { if (sshSession.stream) { sshSession.stream.end(); } sshSession.client.end(); activeSessions.delete(sessionId); ws.send( JSON.stringify({ type: "disconnected", message: "Disconnected from container", }), ); } break; } case "ping": { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "pong" })); } break; } default: sshLogger.warn("Unknown message type", { operation: "ws_message", type: message.type, }); } } catch (error) { sshLogger.error("WebSocket message error", error, { operation: "ws_message", sessionId, }); ws.send( JSON.stringify({ type: "error", message: error instanceof Error ? error.message : "An error occurred", }), ); } }); ws.on("close", () => { clearInterval(wsPingInterval); sshLogger.info("Docker console disconnected", { operation: "docker_console_disconnect", sessionId, userId, hostId: sshSession?.hostId, containerId: sshSession?.containerId, }); if (sshSession) { if (sshSession.stream) { sshSession.stream.end(); } sshSession.client.end(); activeSessions.delete(sessionId); } }); ws.on("error", (error) => { sshLogger.error("WebSocket error", error, { operation: "ws_error", sessionId, }); if (sshSession) { if (sshSession.stream) { sshSession.stream.end(); } sshSession.client.end(); activeSessions.delete(sessionId); } }); }); process.on("SIGTERM", () => { activeSessions.forEach((session) => { if (session.stream) { session.stream.end(); } session.client.end(); }); activeSessions.clear(); wss.close(() => { process.exit(0); }); }); ================================================ FILE: src/backend/ssh/docker.ts ================================================ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { hosts, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { logger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import type { SSHHost, ProxyNode } from "../../types/index.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; const sshLogger = logger; function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, details?: Record, ): Omit { return { type, stage, message, details, }; } interface SSHSession { client: SSHClient; isConnected: boolean; lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; hostId?: number; } interface PendingTOTPSession { client: SSHClient; finish: (responses: string[]) => void; config: Record; createdAt: number; sessionId: string; hostId?: number; ip?: string; port?: number; username?: string; userId?: string; prompts?: Array<{ prompt: string; echo: boolean }>; totpPromptIndex?: number; resolvedPassword?: string; totpAttempts: number; isWarpgate?: boolean; } const sshSessions: Record = {}; const pendingTOTPSessions: Record = {}; const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; setInterval(() => { const now = Date.now(); Object.keys(pendingTOTPSessions).forEach((sessionId) => { const session = pendingTOTPSessions[sessionId]; if (now - session.createdAt > 180000) { try { session.client.end(); } catch { // expected } delete pendingTOTPSessions[sessionId]; } }); }, 60000); function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { if (session.activeOperations > 0) { sshLogger.warn( `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, { operation: "cleanup_deferred", sessionId, activeOperations: session.activeOperations, }, ); scheduleSessionCleanup(sessionId); return; } try { session.client.end(); } catch { // expected } clearTimeout(session.timeout); delete sshSessions[sessionId]; } } function scheduleSessionCleanup(sessionId: string) { const session = sshSessions[sessionId]; if (session) { if (session.timeout) clearTimeout(session.timeout); session.timeout = setTimeout(() => { cleanupSession(sessionId); }, SESSION_IDLE_TIMEOUT); } } interface JumpHostConfig { id: number; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; [key: string]: unknown; } async function resolveJumpHost( hostId: number, userId: string, ): Promise { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResults.length === 0) { return null; } const host = hostResults[0]; if (host.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; return { ...host, password: credential.password as string | undefined, key: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, } as JumpHostConfig; } } return host as JumpHostConfig; } catch (error) { sshLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", hostId, userId, }); return null; } } async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } let currentClient: SSHClient | null = null; const clients: SSHClient[] = []; try { const jumpHostConfigs = await Promise.all( jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), ); const totalHops = jumpHostConfigs.length; for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { sshLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, hopIndex: i, totalHops, }); clients.forEach((c) => c.end()); return null; } } let proxySocket: import("net").Socket | null = null; if (socks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, socks5Config, ); } for (let i = 0; i < jumpHostConfigs.length; i++) { const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new SSHClient(); clients.push(jumpClient); const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( jumpHostConfig.id, jumpHostConfig.ip, jumpHostConfig.port || 22, null, userId, true, ); const connected = await new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); jumpClient.on("ready", () => { clearTimeout(timeout); resolve(true); }); jumpClient.on("error", (err) => { clearTimeout(timeout); sshLogger.error( `Jump host ${i + 1}/${totalHops} connection failed`, err, { operation: "jump_host_connect", hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, hopIndex: i, totalHops, previousHop: i > 0 ? jumpHostConfigs[i - 1]?.ip : proxySocket ? "proxy" : "direct", usedProxySocket: i === 0 && !!proxySocket, }, ); resolve(false); }); const connectConfig: Record = { host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, readyTimeout: 30000, hostVerifier: jumpHostVerifier, }; if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { connectConfig.password = jumpHostConfig.password; } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { const cleanKey = jumpHostConfig.key .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } } if (currentClient) { currentClient.forwardOut( "127.0.0.1", 0, jumpHostConfig.ip, jumpHostConfig.port || 22, (err, stream) => { if (err) { clearTimeout(timeout); resolve(false); return; } connectConfig.sock = stream; jumpClient.connect(connectConfig); }, ); } else if (proxySocket) { connectConfig.sock = proxySocket; jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } }); if (!connected) { clients.forEach((c) => c.end()); return null; } currentClient = jumpClient; } return currentClient; } catch (error) { sshLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); clients.forEach((c) => c.end()); return null; } } async function executeDockerCommand( session: SSHSession, command: string, sessionId?: string, userId?: string, hostId?: number, ): Promise { const startTime = Date.now(); sshLogger.info("Executing Docker command", { operation: "docker_command_exec", sessionId, userId, hostId, command: command.split(" ")[1], }); return new Promise((resolve, reject) => { session.client.exec(command, (err, stream) => { if (err) { sshLogger.error("Docker command execution error", err, { operation: "execute_docker_command", sessionId, userId, hostId, command: command.split(" ")[1], }); return reject(err); } let stdout = ""; let stderr = ""; stream.on("close", (code: number) => { if (code !== 0) { sshLogger.error("Docker command failed", undefined, { operation: "execute_docker_command", sessionId, userId, hostId, command: command.split(" ")[1], exitCode: code, stderr, }); reject(new Error(stderr || `Command exited with code ${code}`)); } else { sshLogger.success("Docker command completed", { operation: "docker_command_success", sessionId, userId, hostId, command: command.split(" ")[1], duration: Date.now() - startTime, }); resolve(stdout); } }); stream.on("data", (data: Buffer) => { stdout += data.toString(); }); stream.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); stream.on("error", (streamErr: Error) => { sshLogger.error("Docker command stream error", streamErr, { operation: "execute_docker_command", sessionId, userId, hostId, command: command.split(" ")[1], }); reject(streamErr); }); }); }); } const app = express(); app.use( cors({ origin: (origin, callback) => { if (!origin) { return callback(null, true); } if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (allowedOrigins.includes(origin)) { return callback(null, true); } return callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Authorization", "User-Agent", "X-Electron-App", ], }), ); app.use(cookieParser()); app.use(express.json({ limit: "100mb" })); app.use(express.urlencoded({ limit: "100mb", extended: true })); app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); /** * @openapi * /docker/ssh/connect: * post: * summary: Establish SSH session for Docker * description: Establishes an SSH session to a host for Docker operations. * tags: * - Docker * requestBody: * required: true * content: * application/json: * schema: * type: object * responses: * 200: * description: SSH connection established. * 400: * description: Missing sessionId or hostId. * 401: * description: Authentication required. * 403: * description: Docker is not enabled for this host. * 404: * description: Host not found. * 500: * description: SSH connection failed. */ app.post("/docker/ssh/connect", async (req, res) => { const { sessionId, hostId, userProvidedPassword, userProvidedSshKey, userProvidedKeyPassword, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, } = req.body; const userId = (req as unknown as { userId: string }).userId; const connectionLogs: Array> = []; if (!userId) { sshLogger.error("Docker SSH connection rejected: no authenticated user", { operation: "docker_connect_auth", sessionId, }); connectionLogs.push( createConnectionLog( "error", "docker_connecting", "Authentication required", ), ); return res .status(401) .json({ error: "Authentication required", connectionLogs }); } if (!SimpleDBOps.isUserDataUnlocked(userId)) { connectionLogs.push( createConnectionLog("error", "docker_connecting", "Session expired"), ); return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", connectionLogs, }); } if (!sessionId || !hostId) { sshLogger.warn("Missing Docker SSH connection parameters", { operation: "docker_connect", sessionId, hasHostId: !!hostId, }); connectionLogs.push( createConnectionLog( "error", "docker_connecting", "Missing connection parameters", ), ); return res .status(400) .json({ error: "Missing sessionId or hostId", connectionLogs }); } connectionLogs.push( createConnectionLog( "info", "docker_connecting", "Initiating Docker SSH connection", ), ); try { const hostResults = await SimpleDBOps.select( getDb().select().from(hosts).where(eq(hosts.id, hostId)), "ssh_data", userId, ); if (hostResults.length === 0) { connectionLogs.push( createConnectionLog("error", "docker_connecting", "Host not found"), ); return res.status(404).json({ error: "Host not found", connectionLogs }); } const host = hostResults[0] as unknown as SSHHost; if (host.userId !== userId) { const { PermissionManager } = await import("../utils/permission-manager.js"); const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( userId, hostId, "execute", ); if (!accessInfo.hasAccess) { sshLogger.warn("User does not have access to host", { operation: "docker_connect", hostId, userId, }); connectionLogs.push( createConnectionLog( "error", "docker_connecting", "Access denied to host", ), ); return res.status(403).json({ error: "Access denied", connectionLogs }); } } if (typeof host.jumpHosts === "string" && host.jumpHosts) { try { host.jumpHosts = JSON.parse(host.jumpHosts); } catch (e) { sshLogger.error("Failed to parse jump hosts", e, { hostId: host.id, }); host.jumpHosts = []; } } if (!host.enableDocker) { sshLogger.warn("Docker not enabled for host", { operation: "docker_connect", hostId, userId, }); connectionLogs.push( createConnectionLog( "error", "docker_connecting", "Docker is not enabled for this host", ), ); return res.status(403).json({ error: "Docker is not enabled for this host. Enable it in Host Settings.", code: "DOCKER_DISABLED", connectionLogs, }); } connectionLogs.push( createConnectionLog( "info", "docker_auth", "Resolving authentication credentials", ), ); if (sshSessions[sessionId]) { cleanupSession(sessionId); } if (pendingTOTPSessions[sessionId]) { try { pendingTOTPSessions[sessionId].client.end(); } catch { // expected } delete pendingTOTPSessions[sessionId]; } let resolvedCredentials: { password?: string; sshKey?: string; keyPassword?: string; authType?: string; } = { password: host.password, sshKey: host.key, keyPassword: host.keyPassword, authType: host.authType, }; if (userProvidedPassword) { resolvedCredentials.password = userProvidedPassword; } if (userProvidedSshKey) { resolvedCredentials.sshKey = userProvidedSshKey; resolvedCredentials.authType = "key"; } if (userProvidedKeyPassword) { resolvedCredentials.keyPassword = userProvidedKeyPassword; } if (host.credentialId) { const ownerId = host.userId; if (userId !== ownerId) { try { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id, userId, ); if (sharedCred) { resolvedCredentials = { password: sharedCred.password, sshKey: sharedCred.key, keyPassword: sharedCred.keyPassword, authType: sharedCred.authType, }; } } catch (error) { sshLogger.error("Failed to resolve shared credential", error, { operation: "docker_connect", hostId, userId, }); } } else { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, authType: credential.authType as string | undefined, }; } } } const client = new SSHClient(); const config: Record = { host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, port: host.port || 22, username: host.username, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( hostId, host.ip, host.port || 22, null, userId, false, ), }; if (resolvedCredentials.authType === "none") { // no credentials needed } else if (resolvedCredentials.authType === "password") { if (resolvedCredentials.password) { config.password = resolvedCredentials.password; } } else if (resolvedCredentials.authType === "opkssh") { try { const { getOPKSSHToken } = await import("./opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); if (!token) { connectionLogs.push( createConnectionLog( "error", "docker_auth", "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", ), ); return res.status(401).json({ error: "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", requiresOPKSSHAuth: true, connectionLogs, }); } const { promises: fs } = await import("fs"); const path = await import("path"); const os = await import("os"); const tempDir = os.tmpdir(); const keyPath = path.join(tempDir, `opkssh-docker-${userId}-${hostId}`); const certPath = `${keyPath}-cert.pub`; await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 }); await fs.writeFile(certPath, token.sshCert, { mode: 0o600 }); config.privateKey = await fs.readFile(keyPath); connectionLogs.push( createConnectionLog( "info", "docker_auth", "Using OPKSSH certificate authentication", ), ); setTimeout(async () => { try { const cleanupResults = await Promise.allSettled([ fs.unlink(keyPath), fs.unlink(certPath), ]); cleanupResults.forEach((result, index) => { if (result.status === "rejected") { sshLogger.warn(`Failed to cleanup OPKSSH temp file`, { operation: "opkssh_temp_cleanup_failed", file: index === 0 ? "keyPath" : "certPath", sessionId, error: result.reason, }); } }); } catch (error) { sshLogger.error("Failed to cleanup OPKSSH temp files", { operation: "opkssh_temp_cleanup_error", sessionId, error, }); } }, 60000); } catch (opksshError) { sshLogger.error("OPKSSH authentication error for Docker", { operation: "docker_connect", sessionId, hostId, error: opksshError instanceof Error ? opksshError.message : "Unknown error", }); connectionLogs.push( createConnectionLog( "error", "docker_auth", `OPKSSH authentication failed: ${opksshError instanceof Error ? opksshError.message : "Unknown error"}`, ), ); return res.status(500).json({ error: "OPKSSH authentication failed", connectionLogs, }); } } else if ( resolvedCredentials.authType === "key" && 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"); if (resolvedCredentials.keyPassword) { config.passphrase = resolvedCredentials.keyPassword; } } catch (error) { sshLogger.error("SSH key processing error", error, { operation: "docker_connect", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "docker_auth", "SSH key processing error", ), ); return res.status(400).json({ error: "SSH key format error: Invalid private key format", connectionLogs, }); } } else if (resolvedCredentials.authType === "key") { sshLogger.error("SSH key authentication requested but no key provided", { operation: "docker_connect", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "docker_auth", "SSH key authentication requested but no key provided", ), ); return res.status(400).json({ error: "SSH key authentication requested but no key provided", connectionLogs, }); } let responseSent = false; connectionLogs.push( createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), ); connectionLogs.push( createConnectionLog( "info", "tcp", `Connecting to ${host.ip}:${host.port || 22}`, ), ); connectionLogs.push( createConnectionLog("info", "handshake", "Initiating SSH handshake"), ); if (resolvedCredentials.authType === "password") { connectionLogs.push( createConnectionLog("info", "auth", "Authenticating with password"), ); } else if (resolvedCredentials.authType === "key") { connectionLogs.push( createConnectionLog("info", "auth", "Authenticating with SSH key"), ); } else if (resolvedCredentials.authType === "none") { connectionLogs.push( createConnectionLog( "info", "auth", "Attempting keyboard-interactive authentication", ), ); } client.on("ready", () => { if (responseSent) return; responseSent = true; connectionLogs.push( createConnectionLog( "success", "connected", "SSH connection established successfully", ), ); sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now(), activeOperations: 0, hostId, }; scheduleSessionCleanup(sessionId); res.json({ success: true, message: "SSH connection established", connectionLogs, }); }); client.on("error", (err) => { if (responseSent) { sshLogger.error( "Docker SSH connection error after response sent", err, { operation: "docker_connect_after_response", sessionId, hostId, userId, }, ); if (pendingTOTPSessions[sessionId]) { delete pendingTOTPSessions[sessionId]; } return; } responseSent = true; sshLogger.error("Docker SSH connection failed", err, { operation: "docker_connect", sessionId, hostId, userId, }); let errorStage: ConnectionStage = "error"; if ( err.message.includes("ENOTFOUND") || err.message.includes("getaddrinfo") ) { errorStage = "dns"; connectionLogs.push( createConnectionLog( "error", errorStage, `DNS resolution failed: ${err.message}`, ), ); } else if ( err.message.includes("ECONNREFUSED") || err.message.includes("ETIMEDOUT") ) { errorStage = "tcp"; connectionLogs.push( createConnectionLog( "error", errorStage, `TCP connection failed: ${err.message}`, ), ); } else if ( err.message.includes("handshake") || err.message.includes("key exchange") ) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH handshake failed: ${err.message}`, ), ); } else if ( err.message.includes("authentication") || err.message.includes("Authentication") ) { errorStage = "auth"; connectionLogs.push( createConnectionLog( "error", errorStage, `Authentication failed: ${err.message}`, ), ); } else if (err.message.includes("verification failed")) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, ), ); } else { connectionLogs.push( createConnectionLog( "error", "error", `SSH connection failed: ${err.message}`, ), ); } if ( resolvedCredentials.authType === "none" && (err.message.includes("authentication") || err.message.includes("All configured authentication methods failed")) ) { res.json({ status: "auth_required", reason: "no_keyboard", connectionLogs, }); } else { res.status(500).json({ success: false, message: err.message || "SSH connection failed", connectionLogs, }); } }); client.on("close", () => { if (sshSessions[sessionId]) { sshSessions[sessionId].isConnected = false; cleanupSession(sessionId); } if (pendingTOTPSessions[sessionId]) { delete pendingTOTPSessions[sessionId]; } }); client.on( "keyboard-interactive", ( name: string, instructions: string, instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { const promptTexts = prompts.map((p) => p.prompt); const warpgatePattern = /warpgate\s+authentication/i; const isWarpgate = warpgatePattern.test(name) || warpgatePattern.test(instructions) || promptTexts.some((p) => warpgatePattern.test(p)); if (isWarpgate) { const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); const keyMatch = fullText.match( /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, ); if (urlMatch) { if (responseSent) return; responseSent = true; pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip: host.ip, port: host.port || 22, username: host.username, userId, prompts, totpPromptIndex: -1, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, isWarpgate: true, }; connectionLogs.push( createConnectionLog( "info", "docker_auth", "Warpgate authentication required", ), ); res.json({ requires_warpgate: true, sessionId, url: urlMatch[0], securityKey: keyMatch ? keyMatch[1] : "N/A", connectionLogs, }); return; } } const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); if (totpPromptIndex !== -1) { if (responseSent) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip: host.ip, port: host.port || 22, username: host.username, userId, prompts, totpPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, }; connectionLogs.push( createConnectionLog( "info", "docker_auth", "TOTP verification required", ), ); res.json({ requires_totp: true, sessionId, prompt: prompts[totpPromptIndex].prompt, connectionLogs, }); } else { const passwordPromptIndex = prompts.findIndex((p) => /password/i.test(p.prompt), ); if ( resolvedCredentials.authType === "none" && passwordPromptIndex !== -1 ) { if (responseSent) return; responseSent = true; client.end(); res.json({ status: "auth_required", reason: "no_keyboard", }); return; } const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none"; if (!hasStoredPassword && passwordPromptIndex !== -1) { if (responseSent) { const responses = prompts.map((p) => { if ( /password/i.test(p.prompt) && resolvedCredentials.password ) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { const responses = prompts.map((p) => { if ( /password/i.test(p.prompt) && resolvedCredentials.password ) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip: host.ip, port: host.port || 22, username: host.username, userId, prompts, totpPromptIndex: passwordPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, }; res.json({ requires_totp: true, sessionId, prompt: prompts[passwordPromptIndex].prompt, isPassword: true, }); return; } const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); } }, ); const proxyConfig: SOCKS5Config | null = useSocks5 && (socks5Host || (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) ? { useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain: socks5ProxyChain as ProxyNode[], } : null; const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0; if (hasJumpHosts) { try { if (proxyConfig) { connectionLogs.push( createConnectionLog( "info", "proxy", "Connecting via proxy + jump hosts", ), ); } connectionLogs.push( createConnectionLog( "info", "jump", `Connecting via ${host.jumpHosts!.length} jump host(s)`, ), ); const jumpClient = await createJumpHostChain( host.jumpHosts as Array<{ hostId: number }>, userId, proxyConfig, ); if (!jumpClient) { connectionLogs.push( createConnectionLog( "error", "jump", "Failed to establish jump host chain", ), ); return res.status(500).json({ error: "Failed to establish jump host chain", connectionLogs, }); } jumpClient.forwardOut( "127.0.0.1", 0, host.ip, host.port || 22, (err, stream) => { if (err) { sshLogger.error("Failed to forward through jump host", err, { operation: "docker_jump_forward", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "jump", `Failed to forward through jump host: ${err.message}`, ), ); jumpClient.end(); if (!responseSent) { responseSent = true; return res.status(500).json({ error: "Failed to forward through jump host: " + err.message, connectionLogs, }); } return; } config.sock = stream; client.connect(config); }, ); } catch (jumpError) { sshLogger.error("Jump host connection failed", jumpError, { operation: "docker_jump_connect", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "jump", `Jump host connection failed: ${jumpError instanceof Error ? jumpError.message : "Unknown error"}`, ), ); if (!responseSent) { responseSent = true; return res.status(500).json({ error: "Jump host connection failed: " + (jumpError instanceof Error ? jumpError.message : "Unknown error"), connectionLogs, }); } return; } } else if (proxyConfig) { connectionLogs.push( createConnectionLog("info", "proxy", "Connecting via proxy"), ); try { const proxySocket = await createSocks5Connection( host.ip, host.port || 22, proxyConfig, ); if (proxySocket) { config.sock = proxySocket; } client.connect(config); } catch (proxyError) { sshLogger.error("Proxy connection failed", proxyError, { operation: "docker_proxy_connect", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "proxy", `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, ), ); if (!responseSent) { responseSent = true; return res.status(500).json({ error: "Proxy connection failed: " + (proxyError instanceof Error ? proxyError.message : "Unknown error"), connectionLogs, }); } return; } } else { client.connect(config); } } catch (error) { sshLogger.error("Docker SSH connection error", error, { operation: "docker_connect", sessionId, hostId, userId, }); connectionLogs.push( createConnectionLog( "error", "docker_connecting", `Connection error: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); res.status(500).json({ success: false, message: error instanceof Error ? error.message : "Unknown error", connectionLogs, }); } }); /** * @openapi * /docker/ssh/disconnect: * post: * summary: Disconnect SSH session * description: Closes an active SSH session for Docker operations. * tags: * - Docker * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * responses: * 200: * description: SSH session disconnected. * 400: * description: Session ID is required. */ app.post("/docker/ssh/disconnect", async (req, res) => { const { sessionId } = req.body; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } cleanupSession(sessionId); res.json({ success: true, message: "SSH session disconnected" }); }); /** * @openapi * /docker/ssh/connect-totp: * post: * summary: Verify TOTP and complete connection * description: Verifies the TOTP code and completes the SSH connection. * tags: * - Docker * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * totpCode: * type: string * responses: * 200: * description: TOTP verified, SSH connection established. * 400: * description: Session ID and TOTP code required. * 401: * description: Invalid TOTP code. * 404: * description: TOTP session expired. */ app.post("/docker/ssh/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as unknown as { userId: string }).userId; if (!userId) { sshLogger.error("TOTP verification rejected: no authenticated user", { operation: "docker_totp_auth", sessionId, }); return res.status(401).json({ error: "Authentication required" }); } if (!sessionId || !totpCode) { return res.status(400).json({ error: "Session ID and TOTP code required" }); } const session = pendingTOTPSessions[sessionId]; if (!session) { sshLogger.warn("TOTP session not found or expired", { operation: "docker_totp_verify", sessionId, userId, availableSessions: Object.keys(pendingTOTPSessions), }); return res .status(404) .json({ error: "TOTP session expired. Please reconnect." }); } if (Date.now() - session.createdAt > 180000) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } sshLogger.warn("TOTP session timeout before code submission", { operation: "docker_totp_verify", sessionId, userId, age: Date.now() - session.createdAt, }); return res .status(408) .json({ error: "TOTP session timeout. Please reconnect." }); } const responses = (session.prompts || []).map((p, index) => { if (index === session.totpPromptIndex) { return totpCode; } if (/password/i.test(p.prompt) && session.resolvedPassword) { return session.resolvedPassword; } return ""; }); let responseSent = false; const responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; sshLogger.warn("TOTP verification timeout", { operation: "docker_totp_verify", sessionId, userId, }); res.status(408).json({ error: "TOTP verification timeout" }); } }, 60000); session.client.once("ready", () => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; setTimeout(() => { sshSessions[sessionId] = { client: session.client, isConnected: true, lastActive: Date.now(), activeOperations: 0, hostId: session.hostId, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "TOTP verified, SSH connection established", }); if (session.hostId && session.userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where( and( eq(hosts.id, session.hostId!), eq(hosts.userId, session.userId!), ), ), "ssh_data", session.userId!, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${session.username}@${session.ip}:${session.port}`; await axios.post( "http://localhost:30006/activity/log", { type: "docker", hostId: session.hostId, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, }, }, ); } catch (error) { sshLogger.warn("Failed to log Docker activity (TOTP)", { operation: "activity_log_error", userId: session.userId, hostId: session.hostId, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }, 200); }); session.client.once("error", (err) => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; sshLogger.error("TOTP verification failed", { operation: "docker_totp_verify", sessionId, userId, error: err.message, }); res.status(401).json({ status: "error", message: "Invalid TOTP code" }); }); session.finish(responses); }); /** * @openapi * /docker/ssh/connect-warpgate: * post: * summary: Complete Warpgate authentication * description: Submits empty response to complete Warpgate authentication after user completes browser auth. * tags: * - Docker * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - sessionId * properties: * sessionId: * type: string * description: Session ID from initial connection attempt * responses: * 200: * description: Warpgate authentication completed successfully. * 401: * description: Authentication failed or unauthorized. * 404: * description: Warpgate session expired. */ app.post("/docker/ssh/connect-warpgate", async (req, res) => { const { sessionId } = req.body; const userId = (req as unknown as { userId: string }).userId; if (!userId) { sshLogger.error("Warpgate verification rejected: no authenticated user", { operation: "docker_warpgate_auth", sessionId, }); return res.status(401).json({ error: "Authentication required" }); } if (!sessionId) { return res.status(400).json({ error: "Session ID required" }); } const session = pendingTOTPSessions[sessionId]; if (!session) { sshLogger.warn("Warpgate session not found or expired", { operation: "docker_warpgate_verify", sessionId, userId, availableSessions: Object.keys(pendingTOTPSessions), }); return res .status(404) .json({ error: "Warpgate session expired. Please reconnect." }); } if (!session.isWarpgate) { return res.status(400).json({ error: "Session is not a Warpgate session" }); } if (Date.now() - session.createdAt > 300000) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } sshLogger.warn("Warpgate session timeout before completion", { operation: "docker_warpgate_verify", sessionId, userId, age: Date.now() - session.createdAt, }); return res .status(408) .json({ error: "Warpgate session timeout. Please reconnect." }); } let responseSent = false; const responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; sshLogger.warn("Warpgate verification timeout", { operation: "docker_warpgate_verify", sessionId, userId, }); res.status(408).json({ error: "Warpgate verification timeout" }); } }, 60000); session.client.once("ready", () => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; setTimeout(() => { sshSessions[sessionId] = { client: session.client, isConnected: true, lastActive: Date.now(), activeOperations: 0, hostId: session.hostId, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "Warpgate verified, SSH connection established", }); if (session.hostId && session.userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where( and( eq(hosts.id, session.hostId!), eq(hosts.userId, session.userId!), ), ), "ssh_data", session.userId!, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${session.username}@${session.ip}:${session.port}`; await axios.post( "http://localhost:30006/activity/log", { type: "docker", hostId: session.hostId, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, }, }, ); } catch (error) { sshLogger.warn("Failed to log Docker activity (Warpgate)", { operation: "activity_log_error", userId: session.userId, hostId: session.hostId, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }, 200); }); session.client.once("error", (err) => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; sshLogger.error("Warpgate verification failed", { operation: "docker_warpgate_verify", sessionId, userId, error: err.message, }); res .status(401) .json({ status: "error", message: "Warpgate authentication failed" }); }); session.finish([""]); }); /** * @openapi * /docker/ssh/keepalive: * post: * summary: Keep SSH session alive * description: Keeps an active SSH session alive. * tags: * - Docker * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * responses: * 200: * description: Session keepalive successful. * 400: * description: Session ID is required or session not found. */ app.post("/docker/ssh/keepalive", async (req, res) => { const { sessionId } = req.body; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", connected: false, }); } session.lastActive = Date.now(); scheduleSessionCleanup(sessionId); res.json({ success: true, connected: true, message: "Session keepalive successful", lastActive: session.lastActive, }); }); /** * @openapi * /docker/ssh/status: * get: * summary: Check SSH session status * description: Checks the status of an active SSH session. * tags: * - Docker * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * responses: * 200: * description: Session status. * 400: * description: Session ID is required. */ app.get("/docker/ssh/status", async (req, res) => { const sessionId = req.query.sessionId as string; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } const isConnected = !!sshSessions[sessionId]?.isConnected; res.json({ success: true, connected: isConnected }); }); /** * @openapi * /docker/validate/{sessionId}: * get: * summary: Validate Docker availability * description: Validates if Docker is available on the host. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * responses: * 200: * description: Docker availability status. * 400: * description: SSH session not found or not connected. * 500: * description: Validation failed. */ app.get("/docker/validate/:sessionId", async (req, res) => { const { sessionId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } if (pendingTOTPSessions[sessionId]) { return res.status(400).json({ error: "Connection pending authentication", code: "AUTH_PENDING", }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { try { const versionOutput = await executeDockerCommand( session, "docker --version", sessionId, userId, session.hostId, ); const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/); const version = versionMatch ? versionMatch[1] : "unknown"; try { await executeDockerCommand( session, "docker ps >/dev/null 2>&1", sessionId, userId, session.hostId, ); session.activeOperations--; return res.json({ available: true, version, }); } catch (daemonError) { session.activeOperations--; const errorMsg = daemonError instanceof Error ? daemonError.message : ""; 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", code: "DAEMON_NOT_RUNNING", }); } 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", code: "PERMISSION_DENIED", }); } return res.json({ available: false, error: errorMsg, code: "DOCKER_ERROR", }); } } catch { session.activeOperations--; return res.json({ available: false, error: "Docker is not installed on this host. Please install Docker to use this feature.", code: "NOT_INSTALLED", }); } } catch (error) { session.activeOperations--; sshLogger.error("Docker validation error", error, { operation: "docker_validate", sessionId, userId, }); res.status(500).json({ available: false, error: error instanceof Error ? error.message : "Validation failed", }); } }); /** * @openapi * /docker/containers/{sessionId}: * get: * summary: List all containers * description: Lists all Docker containers on the host. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: query * name: all * schema: * type: boolean * responses: * 200: * description: A list of containers. * 400: * description: SSH session not found or not connected. * 500: * description: Failed to list containers. */ app.get("/docker/containers/:sessionId", async (req, res) => { const { sessionId } = req.params; const all = req.query.all !== "false"; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } if (pendingTOTPSessions[sessionId]) { return res.status(400).json({ error: "Connection pending authentication", code: "AUTH_PENDING", }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { const allFlag = all ? "-a " : ""; const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`; const output = await executeDockerCommand( session, command, sessionId, userId, session.hostId, ); const containers = output .split("\n") .filter((line) => line.trim()) .map((line) => { try { return JSON.parse(line); } catch { sshLogger.warn("Failed to parse container line", { operation: "parse_container", line, }); return null; } }) .filter((c) => c !== null); session.activeOperations--; res.json(containers); } catch (error) { session.activeOperations--; sshLogger.error("Failed to list Docker containers", error, { operation: "list_containers", sessionId, userId, }); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to list containers", }); } }); /** * @openapi * /docker/containers/{sessionId}/{containerId}: * get: * summary: Get container details * description: Retrieves detailed information about a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container details. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to get container details. */ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { const command = `docker inspect ${containerId}`; const output = await executeDockerCommand( session, command, sessionId, userId, session.hostId, ); const details = JSON.parse(output); session.activeOperations--; if (details && details.length > 0) { res.json(details[0]); } else { res.status(404).json({ error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to get container details", error, { operation: "get_container_details", sessionId, containerId, userId, }); res.status(500).json({ error: errorMsg || "Failed to get container details", }); } }); /** * @openapi * /docker/containers/{sessionId}/{containerId}/start: * post: * summary: Start container * description: Starts a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container started successfully. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to start container. */ app.post( "/docker/containers/:sessionId/:containerId/start", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "start", }); await executeDockerCommand( session, `docker start ${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container started successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to start container", error, { operation: "start_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to start container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/stop: * post: * summary: Stop container * description: Stops a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container stopped successfully. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to stop container. */ app.post( "/docker/containers/:sessionId/:containerId/stop", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "stop", }); await executeDockerCommand( session, `docker stop ${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container stopped successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to stop container", error, { operation: "stop_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to stop container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/restart: * post: * summary: Restart container * description: Restarts a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container restarted successfully. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to restart container. */ app.post( "/docker/containers/:sessionId/:containerId/restart", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "restart", }); await executeDockerCommand( session, `docker restart ${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container restarted successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to restart container", error, { operation: "restart_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to restart container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/pause: * post: * summary: Pause container * description: Pauses a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container paused successfully. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to pause container. */ app.post( "/docker/containers/:sessionId/:containerId/pause", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "pause", }); await executeDockerCommand( session, `docker pause ${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container paused successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to pause container", error, { operation: "pause_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to pause container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/unpause: * post: * summary: Unpause container * description: Unpauses a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container unpaused successfully. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to unpause container. */ app.post( "/docker/containers/:sessionId/:containerId/unpause", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "unpause", }); await executeDockerCommand( session, `docker unpause ${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container unpaused successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to unpause container", error, { operation: "unpause_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to unpause container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/remove: * delete: * summary: Remove container * description: Removes a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * - in: query * name: force * schema: * type: boolean * responses: * 200: * description: Container removed successfully. * 400: * description: SSH session not found or not connected, or cannot remove a running container. * 404: * description: Container not found. * 500: * description: Failed to remove container. */ app.delete( "/docker/containers/:sessionId/:containerId/remove", async (req, res) => { const { sessionId, containerId } = req.params; const force = req.query.force === "true"; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { sshLogger.info("Docker container operation", { operation: "docker_container_op", sessionId, userId, hostId: session.hostId, containerId, action: "remove", }); const forceFlag = force ? "-f " : ""; await executeDockerCommand( session, `docker rm ${forceFlag}${containerId}`, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, message: "Container removed successfully", }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } if (errorMsg.includes("cannot remove a running container")) { return res.status(400).json({ success: false, error: "Cannot remove a running container. Stop it first or use force.", code: "CONTAINER_RUNNING", }); } sshLogger.error("Failed to remove container", error, { operation: "remove_container", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to remove container", }); } }, ); /** * @openapi * /docker/containers/{sessionId}/{containerId}/logs: * get: * summary: Get container logs * description: Retrieves logs for a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * - in: query * name: tail * schema: * type: integer * - in: query * name: timestamps * schema: * type: boolean * - in: query * name: since * schema: * type: string * - in: query * name: until * schema: * type: string * responses: * 200: * description: Container logs. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to get container logs. */ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { const { sessionId, containerId } = req.params; const tail = req.query.tail ? parseInt(req.query.tail as string) : 100; const timestamps = req.query.timestamps === "true"; const since = req.query.since as string; const until = req.query.until as string; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { let command = `docker logs ${containerId}`; if (tail && tail > 0) { command += ` --tail ${tail}`; } if (timestamps) { command += " --timestamps"; } if (since) { command += ` --since ${since}`; } if (until) { command += ` --until ${until}`; } const logs = await executeDockerCommand( session, command, sessionId, userId, session.hostId, ); session.activeOperations--; res.json({ success: true, logs, }); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to get container logs", error, { operation: "get_logs", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to get container logs", }); } }); /** * @openapi * /docker/containers/{sessionId}/{containerId}/stats: * get: * summary: Get container stats * description: Retrieves stats for a specific container. * tags: * - Docker * parameters: * - in: path * name: sessionId * required: true * schema: * type: string * - in: path * name: containerId * required: true * schema: * type: string * responses: * 200: * description: Container stats. * 400: * description: SSH session not found or not connected. * 404: * description: Container not found. * 500: * description: Failed to get container stats. */ app.get( "/docker/containers/:sessionId/:containerId/stats", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as unknown as { userId: string }).userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", }); } session.lastActive = Date.now(); session.activeOperations++; try { const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`; const output = await executeDockerCommand( session, command, sessionId, userId, session.hostId, ); const rawStats = JSON.parse(output.trim()); const memoryParts = rawStats.memory.split(" / "); const memoryUsed = memoryParts[0]?.trim() || "0B"; const memoryLimit = memoryParts[1]?.trim() || "0B"; const netIOParts = rawStats.netIO.split(" / "); const netInput = netIOParts[0]?.trim() || "0B"; const netOutput = netIOParts[1]?.trim() || "0B"; const blockIOParts = rawStats.blockIO.split(" / "); const blockRead = blockIOParts[0]?.trim() || "0B"; const blockWrite = blockIOParts[1]?.trim() || "0B"; const stats = { cpu: rawStats.cpu, memoryUsed, memoryLimit, memoryPercent: rawStats.memoryPercent, netInput, netOutput, blockRead, blockWrite, pids: rawStats.pids, }; session.activeOperations--; res.json(stats); } catch (error) { session.activeOperations--; const errorMsg = error instanceof Error ? error.message : ""; if (errorMsg.includes("No such container")) { return res.status(404).json({ success: false, error: "Container not found", code: "CONTAINER_NOT_FOUND", }); } sshLogger.error("Failed to get container stats", error, { operation: "get_stats", sessionId, containerId, userId, }); res.status(500).json({ success: false, error: errorMsg || "Failed to get container stats", }); } }, ); const PORT = 30007; app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { sshLogger.error("Failed to initialize Docker backend", err, { operation: "startup", }); } }); process.on("SIGINT", () => { Object.keys(sshSessions).forEach((sessionId) => { cleanupSession(sessionId); }); process.exit(0); }); process.on("SIGTERM", () => { Object.keys(sshSessions).forEach((sessionId) => { cleanupSession(sessionId); }); process.exit(0); }); ================================================ FILE: src/backend/ssh/file-manager.ts ================================================ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import axios from "axios"; import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshCredentials, hosts } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { fileLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, details?: Record, ): Omit { return { type, stage, message, details, }; } function isExecutableFile(permissions: string, fileName: string): boolean { const hasExecutePermission = permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x"; const scriptExtensions = [ ".sh", ".py", ".pl", ".rb", ".js", ".php", ".bash", ".zsh", ".fish", ]; const hasScriptExtension = scriptExtensions.some((ext) => fileName.toLowerCase().endsWith(ext), ); const executableExtensions = [".bin", ".exe", ".out"]; const hasExecutableExtension = executableExtensions.some((ext) => fileName.toLowerCase().endsWith(ext), ); const hasNoExtension = !fileName.includes(".") && hasExecutePermission; return ( hasExecutePermission && (hasScriptExtension || hasExecutableExtension || hasNoExtension) ); } function modeToPermissions(mode: number): string { const S_IFDIR = 0o040000; const S_IFLNK = 0o120000; const S_IFMT = 0o170000; const type = mode & S_IFMT; const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-"; const perms = [ mode & 0o400 ? "r" : "-", mode & 0o200 ? "w" : "-", mode & 0o100 ? "x" : "-", mode & 0o040 ? "r" : "-", mode & 0o020 ? "w" : "-", mode & 0o010 ? "x" : "-", mode & 0o004 ? "r" : "-", mode & 0o002 ? "w" : "-", mode & 0o001 ? "x" : "-", ].join(""); return prefix + perms; } function formatMtime(mtime: number): string { const date = new Date(mtime * 1000); const months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; const month = months[date.getMonth()]; const day = date.getDate().toString().padStart(2, " "); const now = new Date(); const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000); if (date > sixMonthsAgo) { const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); return `${month} ${day} ${hours}:${minutes}`; } return `${month} ${day} ${date.getFullYear()}`; } const app = express(); app.use( cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } if (allowedOrigins.includes(origin)) { return callback(null, true); } callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Authorization", "User-Agent", "X-Electron-App", ], }), ); app.use(cookieParser()); app.use(express.json({ limit: "1gb" })); app.use(express.urlencoded({ limit: "1gb", extended: true })); app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); interface JumpHostConfig { id: number; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; [key: string]: unknown; } async function resolveJumpHost( hostId: number, userId: string, ): Promise { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResults.length === 0) { return null; } const host = hostResults[0]; if (host.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; return { ...host, password: credential.password as string | undefined, key: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, } as JumpHostConfig; } } return host as JumpHostConfig; } catch (error) { fileLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", hostId, userId, }); return null; } } async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } let currentClient: SSHClient | null = null; const clients: SSHClient[] = []; try { const jumpHostConfigs: Array>> = []; for (let i = 0; i < jumpHosts.length; i++) { const config = await resolveJumpHost(jumpHosts[i].hostId, userId); jumpHostConfigs.push(config); } const totalHops = jumpHostConfigs.length; for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { fileLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, hopIndex: i, totalHops, }); clients.forEach((c) => c.end()); return null; } } let proxySocket: import("net").Socket | null = null; if (socks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, socks5Config, ); } for (let i = 0; i < jumpHostConfigs.length; i++) { const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new SSHClient(); clients.push(jumpClient); const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( jumpHostConfig.id, jumpHostConfig.ip, jumpHostConfig.port || 22, null, userId, true, ); const connected = await new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); jumpClient.on("ready", () => { clearTimeout(timeout); resolve(true); }); jumpClient.on("error", (err) => { clearTimeout(timeout); fileLogger.error( `Jump host ${i + 1}/${totalHops} connection failed`, err, { operation: "jump_host_connect", hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, hopIndex: i, totalHops, previousHop: i > 0 ? jumpHostConfigs[i - 1]?.ip : proxySocket ? "proxy" : "direct", usedProxySocket: i === 0 && !!proxySocket, }, ); resolve(false); }); const connectConfig: Record = { host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, readyTimeout: 30000, hostVerifier: jumpHostVerifier, }; if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { connectConfig.password = jumpHostConfig.password; } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { const cleanKey = jumpHostConfig.key .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } } if (currentClient) { currentClient.forwardOut( "127.0.0.1", 0, jumpHostConfig.ip, jumpHostConfig.port || 22, (err, stream) => { if (err) { clearTimeout(timeout); resolve(false); return; } connectConfig.sock = stream; jumpClient.connect(connectConfig); }, ); } else if (proxySocket) { connectConfig.sock = proxySocket; jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } }); if (!connected) { clients.forEach((c) => c.end()); return null; } currentClient = jumpClient; } return currentClient; } catch (error) { fileLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); clients.forEach((c) => c.end()); return null; } } interface SSHSession { client: SSHClient; isConnected: boolean; lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; sudoPassword?: string; sftp?: import("ssh2").SFTPWrapper; poolKey?: string; } interface PendingTOTPSession { client: SSHClient; finish: (responses: string[]) => void; config: import("ssh2").ConnectConfig; createdAt: number; sessionId: string; hostId?: number; ip?: string; port?: number; username?: string; userId?: string; prompts?: Array<{ prompt: string; echo: boolean }>; totpPromptIndex?: number; resolvedPassword?: string; totpAttempts: number; isWarpgate?: boolean; } const sshSessions: Record = {}; const pendingTOTPSessions: Record = {}; function execWithSudo( client: SSHClient, command: string, sudoPassword: string, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; client.exec(sudoCommand, (err, stream) => { if (err) { resolve({ stdout: "", stderr: err.message, code: 1 }); return; } let stdout = ""; let stderr = ""; stream.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); stream.on("close", (code: number) => { stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, ""); resolve({ stdout, stderr, code: code || 0 }); }); stream.on("error", (streamErr: Error) => { resolve({ stdout, stderr: streamErr.message, code: 1 }); }); }); }); } function getSessionSftp( session: SSHSession, ): Promise { if (session.sftp) { return Promise.resolve(session.sftp); } return new Promise((resolve, reject) => { session.client.sftp((err, sftp) => { if (err) { return reject(err); } session.sftp = sftp; sftp.on("error", () => { session.sftp = undefined; }); sftp.on("close", () => { session.sftp = undefined; }); resolve(sftp); }); }); } function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { if (session.activeOperations > 0) { fileLogger.warn( `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, { operation: "cleanup_deferred", sessionId, activeOperations: session.activeOperations, }, ); scheduleSessionCleanup(sessionId); return; } try { if (session.sftp) { session.sftp.end(); session.sftp = undefined; } } catch { // expected } try { session.client.end(); } catch { // expected } clearTimeout(session.timeout); delete sshSessions[sessionId]; } } function scheduleSessionCleanup(sessionId: string) { const session = sshSessions[sessionId]; if (session) { if (session.timeout) clearTimeout(session.timeout); session.timeout = setTimeout( () => { cleanupSession(sessionId); }, 30 * 60 * 1000, ); } } function getMimeType(fileName: string): string { const ext = fileName.split(".").pop()?.toLowerCase(); const mimeTypes: Record = { txt: "text/plain", json: "application/json", js: "text/javascript", html: "text/html", css: "text/css", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", pdf: "application/pdf", zip: "application/zip", tar: "application/x-tar", gz: "application/gzip", }; return mimeTypes[ext || ""] || "application/octet-stream"; } function detectBinary(buffer: Buffer): boolean { if (buffer.length === 0) return false; const sampleSize = Math.min(buffer.length, 8192); let nullBytes = 0; for (let i = 0; i < sampleSize; i++) { const byte = buffer[i]; if (byte === 0) { nullBytes++; } if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { if (++nullBytes > 1) return true; } } return nullBytes / sampleSize > 0.01; } /** * @openapi * /ssh/file_manager/ssh/connect: * post: * summary: Connect to SSH for file management * description: Establishes an SSH/SFTP connection for file manager operations. Supports password, key-based, and keyboard-interactive authentication, as well as jump hosts and SOCKS5 proxies. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - sessionId * - ip * - port * - username * properties: * sessionId: * type: string * description: Unique session identifier * hostId: * type: number * description: Host ID from database * ip: * type: string * description: SSH server IP address * port: * type: number * description: SSH server port * username: * type: string * description: SSH username * password: * type: string * description: SSH password (for password auth) * sshKey: * type: string * description: SSH private key (for key-based auth) * keyPassword: * type: string * description: Private key passphrase * authType: * type: string * enum: [password, key, none] * description: Authentication method * credentialId: * type: number * description: Credential ID to use from database * userProvidedPassword: * type: string * description: User-provided password for keyboard-interactive auth * forceKeyboardInteractive: * type: boolean * description: Force keyboard-interactive authentication * jumpHosts: * type: array * description: Jump host configuration * items: * type: object * properties: * hostId: * type: number * useSocks5: * type: boolean * description: Use SOCKS5 proxy * socks5Host: * type: string * description: SOCKS5 proxy host * socks5Port: * type: number * description: SOCKS5 proxy port * socks5Username: * type: string * description: SOCKS5 proxy username * socks5Password: * type: string * description: SOCKS5 proxy password * socks5ProxyChain: * type: array * description: Chain of SOCKS5 proxies * responses: * 200: * description: SSH connection established successfully, or requires TOTP/Warpgate authentication. * content: * application/json: * schema: * oneOf: * - type: object * properties: * status: * type: string * example: success * message: * type: string * connectionLogs: * type: array * - type: object * properties: * requires_totp: * type: boolean * sessionId: * type: string * prompt: * type: string * connectionLogs: * type: array * - type: object * properties: * requires_warpgate: * type: boolean * sessionId: * type: string * url: * type: string * securityKey: * type: string * connectionLogs: * type: array * - type: object * properties: * status: * type: string * example: auth_required * reason: * type: string * connectionLogs: * type: array * 400: * description: Missing required parameters or invalid SSH key format. * 401: * description: Authentication required. * 500: * description: SSH connection failed. */ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const { sessionId, hostId, ip, port, username, password, sshKey, keyPassword, authType, credentialId, jumpHosts, useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain, } = req.body; const userId = (req as AuthenticatedRequest).userId; const connectionLogs: Array> = []; connectionLogs.push( createConnectionLog( "info", "sftp_connecting", `Initiating SFTP connection to ${username}@${ip}:${port}`, ), ); if (!userId) { fileLogger.error("SSH connection rejected: no authenticated user", { operation: "file_connect_auth", sessionId, }); connectionLogs.push( createConnectionLog( "error", "sftp_auth", "Authentication required - no user session", ), ); return res .status(401) .json({ error: "Authentication required", connectionLogs }); } if (!sessionId || !ip || !username || !port) { fileLogger.warn("Missing SSH connection parameters for file manager", { operation: "file_connect", sessionId, hasIp: !!ip, hasUsername: !!username, hasPort: !!port, }); connectionLogs.push( createConnectionLog( "error", "sftp_connecting", "Missing required connection parameters", ), ); return res .status(400) .json({ error: "Missing SSH connection parameters", connectionLogs }); } if (sshSessions[sessionId]?.isConnected) { cleanupSession(sessionId); } if (pendingTOTPSessions[sessionId]) { try { pendingTOTPSessions[sessionId].client.end(); } catch { // expected } delete pendingTOTPSessions[sessionId]; } const client = new SSHClient(); connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Resolving authentication credentials", ), ); let resolvedCredentials = { password, sshKey, keyPassword, authType }; if (credentialId && hostId && userId) { const hostRow = await getDb() .select({ userId: hosts.userId }) .from(hosts) .where(eq(hosts.id, hostId)) .limit(1); const ownerId = hostRow[0]?.userId ?? null; if (ownerId && userId !== ownerId) { try { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( hostId, userId, ); if (sharedCred) { resolvedCredentials = { password: sharedCred.password, sshKey: sharedCred.key, keyPassword: sharedCred.keyPassword, authType: sharedCred.authType, }; connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Credentials resolved from shared credential store", ), ); } else { fileLogger.warn(`No shared credentials found for host ${hostId}`, { operation: "ssh_credentials", hostId, userId, }); connectionLogs.push( createConnectionLog( "warning", "sftp_auth", "No shared credentials found, using provided credentials", ), ); } } catch (error) { fileLogger.warn( `Failed to resolve shared credential for host ${hostId}`, { operation: "ssh_credentials", hostId, error: error instanceof Error ? error.message : "Unknown error", }, ); connectionLogs.push( createConnectionLog( "warning", "sftp_auth", `Failed to resolve shared credentials: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); } } else if (ownerId) { try { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", ownerId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { password: credential.password, sshKey: credential.privateKey, keyPassword: credential.keyPassword, authType: credential.authType, }; connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Credentials resolved from credential store", ), ); } else { fileLogger.warn(`No credentials found for host ${hostId}`, { operation: "ssh_credentials", hostId, credentialId, userId: ownerId, }); connectionLogs.push( createConnectionLog( "warning", "sftp_auth", "No stored credentials found, using provided credentials", ), ); } } catch (error) { fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, { operation: "ssh_credentials", hostId, credentialId, error: error instanceof Error ? error.message : "Unknown error", }); connectionLogs.push( createConnectionLog( "warning", "sftp_auth", `Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); } } else { fileLogger.warn( "Missing userId for credential resolution in file manager", { operation: "ssh_credentials", hostId, credentialId, }, ); } } else if (credentialId && hostId) { fileLogger.warn( "Missing userId for credential resolution in file manager", { operation: "ssh_credentials", hostId, credentialId, hasUserId: !!userId, }, ); } const config: Record = { host: ip?.replace(/^\[|\]$/g, "") || ip, port, username, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( hostId, ip, port, null, userId, false, ), env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", LC_MESSAGES: "en_US.UTF-8", LC_MONETARY: "en_US.UTF-8", LC_NUMERIC: "en_US.UTF-8", LC_TIME: "en_US.UTF-8", LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, algorithms: { kex: [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", ], serverHostKey: [ "ssh-ed25519", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ssh-dss", ], cipher: [ "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes128-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr", "aes256-cbc", "aes192-cbc", "aes128-cbc", "3des-cbc", ], hmac: [ "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], compress: ["none", "zlib@openssh.com", "zlib"], }, }; if ( resolvedCredentials.authType === "key" && resolvedCredentials.sshKey && 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"); if (resolvedCredentials.keyPassword) config.passphrase = resolvedCredentials.keyPassword; connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Using SSH key authentication", ), ); } catch (keyError) { fileLogger.error("SSH key format error for file manager", { operation: "file_connect", sessionId, hostId, error: keyError.message, }); connectionLogs.push( createConnectionLog( "error", "sftp_auth", `Invalid SSH key format: ${keyError.message}`, ), ); return res .status(400) .json({ error: "Invalid SSH key format", connectionLogs }); } } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) { connectionLogs.push( createConnectionLog( "error", "sftp_auth", "Password required for password authentication", ), ); return res.status(400).json({ error: "Password required for password authentication", connectionLogs, }); } config.password = resolvedCredentials.password; connectionLogs.push( createConnectionLog("info", "sftp_auth", "Using password authentication"), ); } else if (resolvedCredentials.authType === "opkssh") { try { const { getOPKSSHToken } = await import("./opkssh-auth.js"); const token = await getOPKSSHToken(userId, hostId); if (!token) { connectionLogs.push( createConnectionLog( "error", "sftp_auth", "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", ), ); return res.status(401).json({ error: "OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.", requiresOPKSSHAuth: true, connectionLogs, }); } const { promises: fs } = await import("fs"); const path = await import("path"); const os = await import("os"); const tempDir = os.tmpdir(); const keyPath = path.join(tempDir, `opkssh-fm-${userId}-${hostId}`); const certPath = `${keyPath}-cert.pub`; await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 }); await fs.writeFile(certPath, token.sshCert, { mode: 0o600 }); config.privateKey = await fs.readFile(keyPath); connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Using OPKSSH certificate authentication", ), ); setTimeout(async () => { try { const cleanupResults = await Promise.allSettled([ fs.unlink(keyPath), fs.unlink(certPath), ]); cleanupResults.forEach((result, index) => { if (result.status === "rejected") { fileLogger.warn(`Failed to cleanup OPKSSH temp file`, { operation: "opkssh_temp_cleanup_failed", file: index === 0 ? "keyPath" : "certPath", sessionId, error: result.reason, }); } }); } catch (error) { fileLogger.error("Failed to cleanup OPKSSH temp files", { operation: "opkssh_temp_cleanup_error", sessionId, error, }); } }, 60000); } catch (opksshError) { fileLogger.error("OPKSSH authentication error for file manager", { operation: "file_connect", sessionId, hostId, error: opksshError instanceof Error ? opksshError.message : "Unknown error", }); connectionLogs.push( createConnectionLog( "error", "sftp_auth", `OPKSSH authentication failed: ${opksshError instanceof Error ? opksshError.message : "Unknown error"}`, ), ); return res.status(500).json({ error: "OPKSSH authentication failed", connectionLogs, }); } } else if (resolvedCredentials.authType === "none") { connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Using keyboard-interactive authentication", ), ); } else { fileLogger.warn( "No valid authentication method provided for file manager", { operation: "file_connect", sessionId, hostId, authType: resolvedCredentials.authType, hasPassword: !!resolvedCredentials.password, hasKey: !!resolvedCredentials.sshKey, }, ); connectionLogs.push( createConnectionLog( "error", "sftp_auth", "No valid authentication method provided", ), ); return res.status(400).json({ error: "Either password or SSH key must be provided", connectionLogs, }); } let responseSent = false; connectionLogs.push( createConnectionLog("info", "dns", `Resolving DNS for ${ip}`), ); connectionLogs.push( createConnectionLog("info", "tcp", `Connecting to ${ip}:${port}`), ); connectionLogs.push( createConnectionLog("info", "handshake", "Initiating SSH handshake"), ); connectionLogs.push( createConnectionLog( "info", "sftp_connecting", "Establishing SSH connection...", ), ); client.on("ready", () => { if (responseSent) return; responseSent = true; fileLogger.info("File manager SSH connection established", { operation: "file_ssh_connected", sessionId, userId, hostId, ip, port, username, }); connectionLogs.push( createConnectionLog( "success", "connected", "SSH connection established successfully", ), ); connectionLogs.push( createConnectionLog( "success", "sftp_connected", "SFTP session established successfully", ), ); sshSessions[sessionId] = { client, isConnected: true, lastActive: Date.now(), activeOperations: 0, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "SSH connection established", connectionLogs, }); if (hostId && userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${username}@${ip}:${port}`; const authManager = AuthManager.getInstance(); await axios.post( "http://localhost:30006/activity/log", { type: "file_manager", hostId, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`, }, }, ); } catch (error) { fileLogger.warn("Failed to log file manager activity", { operation: "activity_log_error", userId, hostId, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }); client.on("error", (err) => { if (responseSent) return; responseSent = true; fileLogger.error("SSH connection failed for file manager", { operation: "file_connect", sessionId, hostId, ip, port, username, error: err.message, }); let errorStage: ConnectionStage = "error"; if ( err.message.includes("ENOTFOUND") || err.message.includes("getaddrinfo") ) { errorStage = "dns"; connectionLogs.push( createConnectionLog( "error", errorStage, `DNS resolution failed: ${err.message}`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } else if ( err.message.includes("ECONNREFUSED") || err.message.includes("ETIMEDOUT") ) { errorStage = "tcp"; connectionLogs.push( createConnectionLog( "error", errorStage, `TCP connection failed: ${err.message}`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } else if ( err.message.includes("handshake") || err.message.includes("key exchange") ) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH handshake failed: ${err.message}`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } else if ( err.message.includes("authentication") || err.message.includes("Authentication") ) { errorStage = "auth"; connectionLogs.push( createConnectionLog( "error", errorStage, `Authentication failed: ${err.message}`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } else if (err.message.includes("verification failed")) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } else { connectionLogs.push( createConnectionLog( "error", "error", `SSH connection failed: ${err.message}`, { errorCode: (err as unknown as Record).code, errorLevel: (err as unknown as Record).level, }, ), ); } if ( resolvedCredentials.authType === "none" && (err.message.includes("authentication") || err.message.includes("All configured authentication methods failed")) ) { res.json({ status: "auth_required", reason: "no_keyboard", connectionLogs, }); } else { res .status(500) .json({ status: "error", message: err.message, connectionLogs }); } }); client.on("close", () => { fileLogger.info("File manager SSH connection closed", { operation: "file_ssh_disconnected", sessionId, userId, hostId, }); if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false; cleanupSession(sessionId); }); client.on( "keyboard-interactive", ( name: string, instructions: string, instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { const promptTexts = prompts.map((p) => p.prompt); const warpgatePattern = /warpgate\s+authentication/i; const isWarpgate = warpgatePattern.test(name) || warpgatePattern.test(instructions) || promptTexts.some((p) => warpgatePattern.test(p)); if (isWarpgate) { const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`; const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i); const keyMatch = fullText.match( /security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i, ); if (urlMatch) { if (responseSent) return; responseSent = true; connectionLogs.push( createConnectionLog( "info", "sftp_auth", "Warpgate authentication required", { url: urlMatch[0] }, ), ); pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip, port, username, userId, prompts, totpPromptIndex: -1, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, isWarpgate: true, }; res.json({ requires_warpgate: true, sessionId, url: urlMatch[0], securityKey: keyMatch ? keyMatch[1] : "N/A", connectionLogs, }); return; } } const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); if (totpPromptIndex !== -1) { if (responseSent) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } connectionLogs.push( createConnectionLog( "info", "sftp_auth", "TOTP verification required", { prompt: prompts[totpPromptIndex].prompt }, ), ); pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip, port, username, userId, prompts, totpPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, }; res.json({ requires_totp: true, sessionId, prompt: prompts[totpPromptIndex].prompt, connectionLogs, }); } else { const hasStoredPassword = resolvedCredentials.password && resolvedCredentials.authType !== "none"; const passwordPromptIndex = prompts.findIndex((p) => /password/i.test(p.prompt), ); if ( resolvedCredentials.authType === "none" && passwordPromptIndex !== -1 ) { if (responseSent) return; responseSent = true; client.end(); res.json({ status: "auth_required", reason: "no_keyboard", }); return; } if (!hasStoredPassword && passwordPromptIndex !== -1) { if (responseSent) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } responseSent = true; if (pendingTOTPSessions[sessionId]) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); return; } pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId, ip, port, username, userId, prompts, totpPromptIndex: passwordPromptIndex, resolvedPassword: resolvedCredentials.password, totpAttempts: 0, }; res.json({ requires_totp: true, sessionId, prompt: prompts[passwordPromptIndex].prompt, isPassword: true, }); return; } const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && resolvedCredentials.password) { return resolvedCredentials.password; } return ""; }); finish(responses); } }, ); const proxyConfig: SOCKS5Config | null = useSocks5 && (socks5Host || (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0)) ? { useSocks5, socks5Host, socks5Port, socks5Username, socks5Password, socks5ProxyChain: socks5ProxyChain as ProxyNode[], } : null; const hasJumpHosts = jumpHosts && jumpHosts.length > 0 && userId; if (hasJumpHosts) { try { if (proxyConfig) { connectionLogs.push( createConnectionLog( "info", "proxy", "Connecting via proxy + jump hosts", ), ); } connectionLogs.push( createConnectionLog( "info", "jump", `Connecting via ${jumpHosts.length} jump host(s)`, ), ); const jumpClient = await createJumpHostChain( jumpHosts, userId, proxyConfig, ); if (!jumpClient) { fileLogger.error("Failed to establish jump host chain", { operation: "file_jump_chain", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "jump", "Failed to establish jump host chain", ), ); return res.status(500).json({ error: "Failed to connect through jump hosts", connectionLogs, }); } jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { if (err) { fileLogger.error("Failed to forward through jump host", err, { operation: "file_jump_forward", sessionId, hostId, ip, port, }); connectionLogs.push( createConnectionLog( "error", "jump", `Failed to forward through jump host: ${err.message}`, ), ); jumpClient.end(); return res.status(500).json({ error: "Failed to forward through jump host: " + err.message, connectionLogs, }); } config.sock = stream; client.connect(config); }); } catch (error) { fileLogger.error("Jump host error", error, { operation: "file_jump_host", sessionId, hostId, }); connectionLogs.push( createConnectionLog( "error", "jump", `Jump host error: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); return res.status(500).json({ error: "Failed to connect through jump hosts", connectionLogs, }); } } else if (proxyConfig) { connectionLogs.push( createConnectionLog("info", "proxy", "Connecting via proxy", { proxyHost: socks5Host, proxyPort: socks5Port || 1080, }), ); try { const proxySocket = await createSocks5Connection(ip, port, proxyConfig); if (proxySocket) { connectionLogs.push( createConnectionLog( "success", "proxy", "Proxy connected successfully", ), ); config.sock = proxySocket; } client.connect(config); } catch (proxyError) { fileLogger.error("Proxy connection failed", proxyError, { operation: "proxy_connect", sessionId, hostId, proxyHost: socks5Host, proxyPort: socks5Port || 1080, }); connectionLogs.push( createConnectionLog( "error", "proxy", `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`, ), ); return res.status(500).json({ error: "Proxy connection failed: " + (proxyError instanceof Error ? proxyError.message : "Unknown error"), connectionLogs, }); } } else { client.connect(config); } }); /** * @openapi * /ssh/file_manager/ssh/connect-totp: * post: * summary: Verify TOTP and complete connection * description: Verifies the TOTP code and completes the SSH connection for file manager. * tags: * - File Manager * responses: * 200: * description: TOTP verified, SSH connection established. * 400: * description: Session ID and TOTP code required. * 401: * description: Invalid TOTP code or authentication required. * 404: * description: TOTP session expired. * 408: * description: TOTP session timeout. */ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!userId) { fileLogger.error("TOTP verification rejected: no authenticated user", { operation: "file_totp_auth", sessionId, }); return res.status(401).json({ error: "Authentication required" }); } if (!sessionId || !totpCode) { return res.status(400).json({ error: "Session ID and TOTP code required" }); } const session = pendingTOTPSessions[sessionId]; if (!session) { fileLogger.warn("TOTP session not found or expired", { operation: "file_totp_verify", sessionId, userId, availableSessions: Object.keys(pendingTOTPSessions), }); return res .status(404) .json({ error: "TOTP session expired. Please reconnect." }); } if (Date.now() - session.createdAt > 180000) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } fileLogger.warn("TOTP session timeout before code submission", { operation: "file_totp_verify", sessionId, userId, age: Date.now() - session.createdAt, }); return res .status(408) .json({ error: "TOTP session timeout. Please reconnect." }); } const responses = (session.prompts || []).map((p, index) => { if (index === session.totpPromptIndex) { return totpCode; } if (/password/i.test(p.prompt) && session.resolvedPassword) { return session.resolvedPassword; } return ""; }); let responseSent = false; session.client.once("ready", () => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; setTimeout(() => { sshSessions[sessionId] = { client: session.client, isConnected: true, lastActive: Date.now(), activeOperations: 0, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "TOTP verified, SSH connection established", }); if (session.hostId && session.userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where( and( eq(hosts.id, session.hostId!), eq(hosts.userId, session.userId!), ), ), "ssh_data", session.userId!, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${session.username}@${session.ip}:${session.port}`; const authManager = AuthManager.getInstance(); await axios.post( "http://localhost:30006/activity/log", { type: "file_manager", hostId: session.hostId, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, }, }, ); } catch (error) { fileLogger.warn("Failed to log file manager activity (TOTP)", { operation: "activity_log_error", userId: session.userId, hostId: session.hostId, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }, 200); }); session.client.once("error", (err) => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; fileLogger.error("TOTP verification failed", { operation: "file_totp_verify", sessionId, userId, error: err.message, }); res.status(401).json({ status: "error", message: "Invalid TOTP code" }); }); const responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; fileLogger.warn("TOTP verification timeout", { operation: "file_totp_verify", sessionId, userId, }); res.status(408).json({ error: "TOTP verification timeout" }); } }, 60000); session.finish(responses); }); /** * @openapi * /ssh/file_manager/ssh/connect-warpgate: * post: * summary: Complete Warpgate authentication * description: Submits empty response to complete Warpgate authentication after user completes browser auth. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - sessionId * properties: * sessionId: * type: string * description: Session ID from initial connection attempt * responses: * 200: * description: Warpgate authentication completed successfully. * 401: * description: Authentication failed or unauthorized. * 404: * description: Warpgate session expired. * 408: * description: Warpgate session timeout. */ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => { const { sessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!userId) { fileLogger.error("Warpgate verification rejected: no authenticated user", { operation: "file_warpgate_auth", sessionId, }); return res.status(401).json({ error: "Authentication required" }); } if (!sessionId) { return res.status(400).json({ error: "Session ID required" }); } const session = pendingTOTPSessions[sessionId]; if (!session) { fileLogger.warn("Warpgate session not found or expired", { operation: "file_warpgate_verify", sessionId, userId, availableSessions: Object.keys(pendingTOTPSessions), }); return res .status(404) .json({ error: "Warpgate session expired. Please reconnect." }); } if (!session.isWarpgate) { return res.status(400).json({ error: "Session is not a Warpgate session" }); } if (Date.now() - session.createdAt > 300000) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } fileLogger.warn("Warpgate session timeout before completion", { operation: "file_warpgate_verify", sessionId, userId, age: Date.now() - session.createdAt, }); return res .status(408) .json({ error: "Warpgate session timeout. Please reconnect." }); } let responseSent = false; const responseTimeout = setTimeout(() => { if (!responseSent) { responseSent = true; delete pendingTOTPSessions[sessionId]; fileLogger.warn("Warpgate verification timeout", { operation: "file_warpgate_verify", sessionId, userId, }); res.status(408).json({ error: "Warpgate verification timeout" }); } }, 60000); session.client.once("ready", () => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; setTimeout(() => { sshSessions[sessionId] = { client: session.client, isConnected: true, lastActive: Date.now(), activeOperations: 0, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "Warpgate verified, SSH connection established", }); if (session.hostId && session.userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where( and( eq(hosts.id, session.hostId!), eq(hosts.userId, session.userId!), ), ), "ssh_data", session.userId!, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${session.username}@${session.ip}:${session.port}`; await axios.post( "http://localhost:30006/activity/log", { type: "file_manager", hostId: session.hostId, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`, }, }, ); } catch (error) { fileLogger.warn("Failed to log file manager activity", { operation: "activity_log_error", userId: session.userId, hostId: session.hostId, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }, 200); }); session.client.once("error", (err) => { if (responseSent) return; responseSent = true; clearTimeout(responseTimeout); delete pendingTOTPSessions[sessionId]; fileLogger.error("Warpgate verification failed", { operation: "file_warpgate_verify", sessionId, userId, error: err.message, }); res .status(401) .json({ status: "error", message: "Warpgate authentication failed" }); }); session.finish([""]); }); /** * @openapi * /ssh/file_manager/ssh/disconnect: * post: * summary: Disconnect from SSH * description: Closes an active SSH connection for file manager. * tags: * - File Manager * responses: * 200: * description: SSH connection disconnected. */ app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { const { sessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; fileLogger.info("File manager disconnection requested", { operation: "file_disconnect_request", sessionId, userId, }); cleanupSession(sessionId); res.json({ status: "success", message: "SSH connection disconnected" }); }); /** * @openapi * /ssh/file_manager/sudo-password: * post: * summary: Set sudo password for session * description: Stores sudo password temporarily in session for elevated operations. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * password: * type: string * responses: * 200: * description: Sudo password set successfully. * 400: * description: Invalid session. */ app.post("/ssh/file_manager/sudo-password", (req, res) => { const { sessionId, password } = req.body; const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "Invalid or disconnected session" }); } session.sudoPassword = password; session.lastActive = Date.now(); res.json({ status: "success", message: "Sudo password set" }); }); /** * @openapi * /ssh/file_manager/ssh/status: * get: * summary: Get SSH connection status * description: Checks the status of an SSH connection for file manager. * tags: * - File Manager * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * responses: * 200: * description: SSH connection status. */ app.get("/ssh/file_manager/ssh/status", (req, res) => { const sessionId = req.query.sessionId as string; const isConnected = !!sshSessions[sessionId]?.isConnected; res.json({ status: "success", connected: isConnected }); }); /** * @openapi * /ssh/file_manager/ssh/keepalive: * post: * summary: Keep SSH session alive * description: Keeps an active SSH session for file manager alive. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * responses: * 200: * description: Session keepalive successful. * 400: * description: Session ID is required or session not found. */ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { const { sessionId } = req.body; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not found or not connected", connected: false, }); } session.lastActive = Date.now(); scheduleSessionCleanup(sessionId); res.json({ status: "success", connected: true, message: "Session keepalive successful", lastActive: session.lastActive, }); }); /** * @openapi * /ssh/file_manager/ssh/listFiles: * get: * summary: List files in a directory * description: Lists the files and directories in a given path on the remote host. * tags: * - File Manager * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * - in: query * name: path * required: true * schema: * type: string * responses: * 200: * description: A list of files and directories. * 400: * description: Session ID is required or SSH connection not established. * 500: * description: Failed to list files. */ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; const sshPath = decodeURIComponent((req.query.path as string) || "/"); const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } sshConn.lastActive = Date.now(); sshConn.activeOperations++; const trySFTP = () => { try { fileLogger.info("Opening SFTP channel", { operation: "file_sftp_open", sessionId, userId, path: sshPath, }); getSessionSftp(sshConn) .then((sftp) => { sftp.readdir(sshPath, (readdirErr, list) => { if (readdirErr) { fileLogger.warn( `SFTP readdir failed, trying fallback: ${readdirErr.message}`, ); tryFallbackMethod(); return; } const symlinks: Array<{ index: number; path: string }> = []; const files: Array<{ name: string; type: string; size: number | undefined; modified: string; permissions: string; owner: string; group: string; linkTarget: string | undefined; path: string; executable: boolean; }> = []; for (const entry of list) { if (entry.filename === "." || entry.filename === "..") continue; const attrs = entry.attrs; const permissions = modeToPermissions(attrs.mode); const isDirectory = attrs.isDirectory(); const isLink = attrs.isSymbolicLink(); const fileEntry = { name: entry.filename, type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : attrs.size, modified: formatMtime(attrs.mtime), permissions, owner: String(attrs.uid), group: String(attrs.gid), linkTarget: undefined as string | undefined, path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`, executable: !isDirectory && !isLink ? isExecutableFile(permissions, entry.filename) : false, }; if (isLink) { symlinks.push({ index: files.length, path: fileEntry.path }); } files.push(fileEntry); } if (symlinks.length === 0) { sshConn.activeOperations--; return res.json({ files, path: sshPath }); } let resolved = 0; let responded = false; const sendResponse = () => { if (responded) return; responded = true; sshConn.activeOperations--; res.json({ files, path: sshPath }); }; const readlinkTimeout = setTimeout(sendResponse, 5000); for (const link of symlinks) { sftp.readlink(link.path, (linkErr, target) => { resolved++; if (!linkErr && target) { files[link.index].linkTarget = target; } if (resolved === symlinks.length) { clearTimeout(readlinkTimeout); sendResponse(); } }); } }); }) .catch((err: Error) => { fileLogger.warn( `SFTP failed for listFiles, trying fallback: ${err.message}`, ); tryFallbackMethod(); }); } catch (sftpErr: unknown) { const errMsg = sftpErr instanceof Error ? sftpErr.message : "Unknown error"; fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`); tryFallbackMethod(); } }; const tryFallbackMethod = () => { try { const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); sshConn.client.exec( `command ls -la --color=never '${escapedPath}'`, (err, stream) => { if (err) { sshConn.activeOperations--; fileLogger.error("SSH listFiles error:", err); return res.status(500).json({ error: err.message }); } let data = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (code !== 0) { const isPermissionDenied = errorData.toLowerCase().includes("permission denied") || errorData.toLowerCase().includes("access denied"); if (isPermissionDenied) { if (sshConn.sudoPassword) { fileLogger.info( `Permission denied for listFiles, retrying with sudo: ${sshPath}`, ); tryWithSudo(); return; } sshConn.activeOperations--; fileLogger.warn( `Permission denied for listFiles, sudo required: ${sshPath}`, ); return res.status(403).json({ error: `Permission denied: Cannot access ${sshPath}`, needsSudo: true, path: sshPath, }); } sshConn.activeOperations--; fileLogger.error( `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res .status(500) .json({ error: `Command failed: ${errorData}` }); } sshConn.activeOperations--; const lines = data.split("\n").filter((line) => line.trim()); const files = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; const parts = line.split(/\s+/); if (parts.length >= 9) { const permissions = parts[0]; const owner = parts[2]; const group = parts[3]; const size = parseInt(parts[4], 10); let dateStr = ""; const nameStartIndex = 8; if (parts[5] && parts[6] && parts[7]) { dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; } const name = parts.slice(nameStartIndex).join(" "); const isDirectory = permissions.startsWith("d"); const isLink = permissions.startsWith("l"); if (name === "." || name === "..") continue; let actualName = name; let linkTarget = undefined; if (isLink && name.includes(" -> ")) { const linkParts = name.split(" -> "); actualName = linkParts[0]; linkTarget = linkParts[1]; } files.push({ name: actualName, type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : size, modified: dateStr, permissions, owner, group, linkTarget, path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false, }); } } res.json({ files, path: sshPath }); }); }, ); } catch (execErr: unknown) { sshConn.activeOperations--; const errMsg = execErr instanceof Error ? execErr.message : "Unknown error"; fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`); if (!res.headersSent) { return res.status(500).json({ error: errMsg }); } } }; const tryWithSudo = () => { try { const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`; sshConn.client.exec(sudoCommand, (err, stream) => { if (err) { sshConn.activeOperations--; fileLogger.error("SSH sudo listFiles error:", err); return res.status(500).json({ error: err.message }); } let data = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { sshConn.activeOperations--; data = data.replace(/\[sudo\] password for .+?:\s*/g, ""); if ( data.toLowerCase().includes("sorry, try again") || data.toLowerCase().includes("incorrect password") || errorData.toLowerCase().includes("sorry, try again") ) { sshConn.sudoPassword = undefined; return res.status(403).json({ error: "Sudo authentication failed. Please try again.", needsSudo: true, sudoFailed: true, path: sshPath, }); } if (code !== 0 && !data.trim()) { fileLogger.error( `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res .status(500) .json({ error: `Sudo command failed: ${errorData || data}` }); } const lines = data.split("\n").filter((line) => line.trim()); const files: Array<{ name: string; type: string; size: number | undefined; modified: string; permissions: string; owner: string; group: string; linkTarget: string | undefined; path: string; executable: boolean; }> = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]; const parts = line.split(/\s+/); if (parts.length >= 9) { const permissions = parts[0]; const owner = parts[2]; const group = parts[3]; const size = parseInt(parts[4], 10); let dateStr = ""; const nameStartIndex = 8; if (parts[5] && parts[6] && parts[7]) { dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; } const name = parts.slice(nameStartIndex).join(" "); const isDirectory = permissions.startsWith("d"); const isLink = permissions.startsWith("l"); if (name === "." || name === "..") continue; let actualName = name; let linkTarget = undefined; if (isLink && name.includes(" -> ")) { const linkParts = name.split(" -> "); actualName = linkParts[0]; linkTarget = linkParts[1]; } files.push({ name: actualName, type: isDirectory ? "directory" : isLink ? "link" : "file", size: isDirectory ? undefined : size, modified: dateStr, permissions, owner, group, linkTarget, path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, executable: !isDirectory && !isLink ? isExecutableFile(permissions, actualName) : false, }); } } res.json({ files, path: sshPath }); }); }); } catch (execErr: unknown) { sshConn.activeOperations--; const errMsg = execErr instanceof Error ? execErr.message : "Unknown error"; fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`); if (!res.headersSent) { return res.status(500).json({ error: errMsg }); } } }; trySFTP(); }); /** * @openapi * /ssh/file_manager/ssh/identifySymlink: * get: * summary: Identify symbolic link * description: Identifies the target of a symbolic link. * tags: * - File Manager * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * - in: query * name: path * required: true * schema: * type: string * responses: * 200: * description: Symbolic link information. * 400: * description: Missing required parameters or SSH connection not established. * 500: * description: Failed to identify symbolic link. */ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; const linkPath = decodeURIComponent(req.query.path as string); if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!linkPath) { return res.status(400).json({ error: "Link path is required" }); } sshConn.lastActive = Date.now(); const escapedPath = linkPath.replace(/'/g, "'\"'\"'"); const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`; sshConn.client.exec(command, (err, stream) => { if (err) { fileLogger.error("SSH identifySymlink error:", err); return res.status(500).json({ error: err.message }); } let data = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (code !== 0) { fileLogger.error( `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res.status(500).json({ error: `Command failed: ${errorData}` }); } const [fileType, target] = data.trim().split("\n"); res.json({ path: linkPath, target: target, type: fileType.toLowerCase().includes("directory") ? "directory" : "file", }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH identifySymlink stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/resolvePath: * get: * summary: Resolve a path with environment variables * description: Expands environment variables and ~ in a path via the SSH session. * tags: * - File Manager * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * - in: query * name: path * required: true * schema: * type: string * responses: * 200: * description: The resolved absolute path. * 400: * description: Missing required parameters. * 500: * description: Failed to resolve path. */ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; const rawPath = decodeURIComponent(req.query.path as string); if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!rawPath) { return res.status(400).json({ error: "Path is required" }); } sshConn.lastActive = Date.now(); let expandPath = rawPath; if (expandPath.startsWith("~")) { expandPath = "$HOME" + expandPath.substring(1); } const escapedPath = expandPath.replace(/"/g, '\\"'); const command = `echo "${escapedPath}"`; sshConn.client.exec(command, (err, stream) => { if (err) { fileLogger.error("SSH resolvePath error:", err); return res.status(500).json({ error: err.message }); } let data = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (code !== 0) { fileLogger.error( `SSH resolvePath command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); return res.json({ resolvedPath: rawPath }); } const resolved = data.trim(); res.json({ resolvedPath: resolved || rawPath }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH resolvePath stream error:", streamErr); if (!res.headersSent) { res.json({ resolvedPath: rawPath }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/readFile: * get: * summary: Read a file * description: Reads the content of a file from the remote host. * tags: * - File Manager * parameters: * - in: query * name: sessionId * required: true * schema: * type: string * - in: query * name: path * required: true * schema: * type: string * responses: * 200: * description: The content of the file. * 400: * description: Missing required parameters or file too large. * 404: * description: File not found. * 500: * description: Failed to read file. */ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; const filePath = decodeURIComponent(req.query.path as string); const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!filePath) { return res.status(400).json({ error: "File path is required" }); } fileLogger.info("Reading file", { operation: "file_read", sessionId, userId, path: filePath, }); sshConn.lastActive = Date.now(); const MAX_READ_SIZE = 500 * 1024 * 1024; const escapedPath = filePath.replace(/'/g, "'\"'\"'"); sshConn.client.exec( `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, (sizeErr, sizeStream) => { if (sizeErr) { fileLogger.error("SSH file size check error:", sizeErr); return res.status(500).json({ error: sizeErr.message }); } let sizeData = ""; let sizeErrorData = ""; sizeStream.on("data", (chunk: Buffer) => { sizeData += chunk.toString(); }); sizeStream.stderr.on("data", (chunk: Buffer) => { sizeErrorData += chunk.toString(); }); sizeStream.on("close", (sizeCode) => { if (sizeCode !== 0) { const errorLower = sizeErrorData.toLowerCase(); const isFileNotFound = errorLower.includes("no such file or directory") || errorLower.includes("cannot access") || errorLower.includes("not found") || errorLower.includes("resource not found"); fileLogger.error(`File size check failed: ${sizeErrorData}`); return res.status(isFileNotFound ? 404 : 500).json({ error: `Cannot check file size: ${sizeErrorData}`, fileNotFound: isFileNotFound, }); } const fileSize = parseInt(sizeData.trim(), 10); if (isNaN(fileSize)) { fileLogger.error("Invalid file size response:", sizeData); return res.status(500).json({ error: "Cannot determine file size" }); } if (fileSize > MAX_READ_SIZE) { fileLogger.warn("File too large for reading", { operation: "file_read", sessionId, filePath, fileSize, maxSize: MAX_READ_SIZE, }); return res.status(400).json({ error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`, fileSize, maxSize: MAX_READ_SIZE, tooLarge: true, }); } sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { if (err) { fileLogger.error("SSH readFile error:", err); return res.status(500).json({ error: err.message }); } let binaryData = Buffer.alloc(0); let errorData = ""; stream.on("data", (chunk: Buffer) => { binaryData = Buffer.concat([binaryData, chunk]); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (code !== 0) { fileLogger.error( `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); const isFileNotFound = errorData.includes("No such file or directory") || errorData.includes("cannot access") || errorData.includes("not found"); return res.status(isFileNotFound ? 404 : 500).json({ error: `Command failed: ${errorData}`, fileNotFound: isFileNotFound, }); } const isBinary = detectBinary(binaryData); fileLogger.success("File read successfully", { operation: "file_read_success", sessionId, userId, path: filePath, bytes: binaryData.length, }); if (isBinary) { const base64Content = binaryData.toString("base64"); res.json({ content: base64Content, path: filePath, encoding: "base64", }); } else { const textContent = binaryData.toString("utf8"); res.json({ content: textContent, path: filePath, encoding: "utf8", }); } }); }); }); }, ); }); /** * @openapi * /ssh/file_manager/ssh/writeFile: * post: * summary: Write to a file * description: Writes content to a file on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * content: * type: string * responses: * 200: * description: File written successfully. * 400: * description: Missing required parameters or SSH connection not established. * 500: * description: Failed to write file. */ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { const { sessionId, path: filePath, content } = req.body; const sshConn = sshSessions[sessionId]; const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!filePath) { return res.status(400).json({ error: "File path is required" }); } if (content === undefined) { return res.status(400).json({ error: "File content is required" }); } const contentLength = typeof content === "string" ? content.length : Buffer.byteLength(content); fileLogger.info("Writing file", { operation: "file_write", sessionId, userId, path: filePath, bytes: contentLength, }); sshConn.lastActive = Date.now(); const trySFTP = () => { try { fileLogger.info("Opening SFTP channel", { operation: "file_sftp_open", sessionId, userId, path: filePath, }); getSessionSftp(sshConn) .then((sftp) => { let fileBuffer; try { if (typeof content === "string") { try { const testBuffer = Buffer.from(content, "base64"); if (testBuffer.toString("base64") === content) { fileBuffer = testBuffer; } else { fileBuffer = Buffer.from(content, "utf8"); } } catch { fileBuffer = Buffer.from(content, "utf8"); } } else if (Buffer.isBuffer(content)) { fileBuffer = content; } else { fileBuffer = Buffer.from(content); } } catch (bufferErr) { fileLogger.error("Buffer conversion error:", bufferErr); if (!res.headersSent) { return res .status(500) .json({ error: "Invalid file content format" }); } return; } const writeStream = sftp.createWriteStream(filePath); let hasError = false; let hasFinished = false; writeStream.on("error", (streamErr) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( `SFTP write failed, trying fallback method: ${streamErr.message}`, ); tryFallbackMethod(); }); writeStream.on("finish", () => { if (hasError || hasFinished) return; hasFinished = true; fileLogger.success("File written successfully", { operation: "file_write_success", sessionId, userId, path: filePath, bytes: fileBuffer.length, }); if (!res.headersSent) { res.json({ message: "File written successfully", path: filePath, toast: { type: "success", message: `File written: ${filePath}`, }, }); } }); writeStream.on("close", () => { if (hasError || hasFinished) return; hasFinished = true; fileLogger.success("File written successfully", { operation: "file_write_success", sessionId, userId, path: filePath, bytes: fileBuffer.length, }); if (!res.headersSent) { res.json({ message: "File written successfully", path: filePath, toast: { type: "success", message: `File written: ${filePath}`, }, }); } }); try { writeStream.write(fileBuffer); writeStream.end(); } catch (writeErr) { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( `SFTP write operation failed, trying fallback method: ${writeErr.message}`, ); tryFallbackMethod(); } }) .catch((err: Error) => { fileLogger.warn( `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); }); } catch (sftpErr) { fileLogger.warn( `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`, ); tryFallbackMethod(); } }; const tryFallbackMethod = () => { try { let contentBuffer: Buffer; if (typeof content === "string") { try { contentBuffer = Buffer.from(content, "base64"); if (contentBuffer.toString("base64") !== content) { contentBuffer = Buffer.from(content, "utf8"); } } catch { contentBuffer = Buffer.from(content, "utf8"); } } else if (Buffer.isBuffer(content)) { contentBuffer = content; } else { contentBuffer = Buffer.from(content); } const base64Content = contentBuffer.toString("base64"); const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; sshConn.client.exec(writeCommand, (err, stream) => { if (err) { fileLogger.error("Fallback write command failed:", err); if (!res.headersSent) { return res.status(500).json({ error: `Write failed: ${err.message}`, toast: { type: "error", message: `Write failed: ${err.message}`, }, }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { if (!res.headersSent) { res.json({ message: "File written successfully", path: filePath, toast: { type: "success", message: `File written: ${filePath}`, }, }); } } else { fileLogger.error( `Fallback write failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ error: `Write failed: ${errorData}`, toast: { type: "error", message: `Write failed: ${errorData}` }, }); } } }); stream.on("error", (streamErr) => { fileLogger.error("Fallback write stream error:", streamErr); if (!res.headersSent) { res .status(500) .json({ error: `Write stream error: ${streamErr.message}` }); } }); }); } catch (fallbackErr) { fileLogger.error("Fallback method failed:", fallbackErr); if (!res.headersSent) { res .status(500) .json({ error: `All write methods failed: ${fallbackErr.message}` }); } } }; trySFTP(); }); /** * @openapi * /ssh/file_manager/ssh/uploadFile: * post: * summary: Upload a file * description: Uploads a file to the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * content: * type: string * fileName: * type: string * responses: * 200: * description: File uploaded successfully. * 400: * description: Missing required parameters or SSH connection not established. * 500: * description: Failed to upload file. */ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { const { sessionId, path: filePath, content, fileName } = req.body; const sshConn = sshSessions[sessionId]; const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!filePath || !fileName || content === undefined) { return res .status(400) .json({ error: "File path, name, and content are required" }); } sshConn.lastActive = Date.now(); const contentSize = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.length; const fullPath = filePath.endsWith("/") ? filePath + fileName : filePath + "/" + fileName; const uploadStartTime = Date.now(); fileLogger.info("File upload started", { operation: "file_upload_start", sessionId, userId, path: fullPath, bytes: contentSize, }); const trySFTP = () => { try { fileLogger.info("Opening SFTP channel", { operation: "file_sftp_open", sessionId, userId, path: fullPath, }); getSessionSftp(sshConn) .then((sftp) => { let fileBuffer; try { if (typeof content === "string") { fileBuffer = Buffer.from(content, "base64"); } else if (Buffer.isBuffer(content)) { fileBuffer = content; } else { fileBuffer = Buffer.from(content); } } catch (bufferErr) { fileLogger.error("Buffer conversion error:", bufferErr); if (!res.headersSent) { return res .status(500) .json({ error: "Invalid file content format" }); } return; } const writeStream = sftp.createWriteStream(fullPath); let hasError = false; let hasFinished = false; writeStream.on("error", (streamErr) => { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( `SFTP write failed, trying fallback method: ${streamErr.message}`, { operation: "file_upload", sessionId, fileName, fileSize: contentSize, error: streamErr.message, }, ); tryFallbackMethod(); }); writeStream.on("finish", () => { if (hasError || hasFinished) return; hasFinished = true; fileLogger.success("File upload completed", { operation: "file_upload_complete", sessionId, userId, path: fullPath, bytes: fileBuffer.length, duration: Date.now() - uploadStartTime, }); if (!res.headersSent) { res.json({ message: "File uploaded successfully", path: fullPath, toast: { type: "success", message: `File uploaded: ${fullPath}`, }, }); } }); writeStream.on("close", () => { if (hasError || hasFinished) return; hasFinished = true; fileLogger.success("File upload completed", { operation: "file_upload_complete", sessionId, userId, path: fullPath, bytes: fileBuffer.length, duration: Date.now() - uploadStartTime, }); if (!res.headersSent) { res.json({ message: "File uploaded successfully", path: fullPath, toast: { type: "success", message: `File uploaded: ${fullPath}`, }, }); } }); try { writeStream.write(fileBuffer); writeStream.end(); } catch (writeErr) { if (hasError || hasFinished) return; hasError = true; fileLogger.warn( `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`, ); tryFallbackMethod(); } }) .catch((err: Error) => { fileLogger.warn( `SFTP failed, trying fallback method: ${err.message}`, ); tryFallbackMethod(); }); } catch (sftpErr) { fileLogger.warn( `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`, ); tryFallbackMethod(); } }; const tryFallbackMethod = () => { try { let contentBuffer: Buffer; if (typeof content === "string") { try { contentBuffer = Buffer.from(content, "base64"); if (contentBuffer.toString("base64") !== content) { contentBuffer = Buffer.from(content, "utf8"); } } catch { contentBuffer = Buffer.from(content, "utf8"); } } else if (Buffer.isBuffer(content)) { contentBuffer = content; } else { contentBuffer = Buffer.from(content); } const base64Content = contentBuffer.toString("base64"); const chunkSize = 1000000; const chunks = []; for (let i = 0; i < base64Content.length; i += chunkSize) { chunks.push(base64Content.slice(i, i + chunkSize)); } if (chunks.length === 1) { const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; sshConn.client.exec(writeCommand, (err, stream) => { if (err) { fileLogger.error("Fallback upload command failed:", err); if (!res.headersSent) { return res .status(500) .json({ error: `Upload failed: ${err.message}` }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { if (!res.headersSent) { res.json({ message: "File uploaded successfully", path: fullPath, toast: { type: "success", message: `File uploaded: ${fullPath}`, }, }); } } else { fileLogger.error( `Fallback upload failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ error: `Upload failed: ${errorData}`, toast: { type: "error", message: `Upload failed: ${errorData}`, }, }); } } }); stream.on("error", (streamErr) => { fileLogger.error("Fallback upload stream error:", streamErr); if (!res.headersSent) { res .status(500) .json({ error: `Upload stream error: ${streamErr.message}` }); } }); }); } else { const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); let writeCommand = `> '${escapedPath}'`; chunks.forEach((chunk) => { writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`; }); writeCommand += ` && echo "SUCCESS"`; sshConn.client.exec(writeCommand, (err, stream) => { if (err) { fileLogger.error("Chunked fallback upload failed:", err); if (!res.headersSent) { return res .status(500) .json({ error: `Chunked upload failed: ${err.message}` }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { if (!res.headersSent) { res.json({ message: "File uploaded successfully", path: fullPath, toast: { type: "success", message: `File uploaded: ${fullPath}`, }, }); } } else { fileLogger.error( `Chunked fallback upload failed with code ${code}: ${errorData}`, ); if (!res.headersSent) { res.status(500).json({ error: `Chunked upload failed: ${errorData}`, toast: { type: "error", message: `Chunked upload failed: ${errorData}`, }, }); } } }); stream.on("error", (streamErr) => { fileLogger.error( "Chunked fallback upload stream error:", streamErr, ); if (!res.headersSent) { res.status(500).json({ error: `Chunked upload stream error: ${streamErr.message}`, }); } }); }); } } catch (fallbackErr) { fileLogger.error("Fallback method failed:", fallbackErr); if (!res.headersSent) { res .status(500) .json({ error: `All upload methods failed: ${fallbackErr.message}` }); } } }; trySFTP(); }); /** * @openapi * /ssh/file_manager/ssh/createFile: * post: * summary: Create a file * description: Creates an empty file on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * fileName: * type: string * responses: * 200: * description: File created successfully. * 400: * description: Missing required parameters or SSH connection not established. * 403: * description: Permission denied. * 500: * description: Failed to create file. */ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { const { sessionId, path: filePath, fileName } = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!filePath || !fileName) { return res.status(400).json({ error: "File path and name are required" }); } sshConn.lastActive = Date.now(); const fullPath = filePath.endsWith("/") ? filePath + fileName : filePath + "/" + fileName; const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(createCommand, (err, stream) => { if (err) { fileLogger.error("SSH createFile error:", err); if (!res.headersSent) { return res.status(500).json({ error: err.message }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); if (chunk.toString().includes("Permission denied")) { fileLogger.error(`Permission denied creating file: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`, }); } return; } }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { if (!res.headersSent) { res.json({ message: "File created successfully", path: fullPath, toast: { type: "success", message: `File created: ${fullPath}` }, }); } return; } if (code !== 0) { fileLogger.error( `SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ error: `Command failed: ${errorData}`, toast: { type: "error", message: `File creation failed: ${errorData}`, }, }); } return; } if (!res.headersSent) { res.json({ message: "File created successfully", path: fullPath, toast: { type: "success", message: `File created: ${fullPath}` }, }); } }); stream.on("error", (streamErr) => { fileLogger.error("SSH createFile stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/createFolder: * post: * summary: Create a folder * description: Creates a new folder on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * folderName: * type: string * responses: * 200: * description: Folder created successfully. * 400: * description: Missing required parameters or SSH connection not established. * 403: * description: Permission denied. * 500: * description: Failed to create folder. */ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { const { sessionId, path: folderPath, folderName } = req.body; const sshConn = sshSessions[sessionId]; const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!folderPath || !folderName) { return res.status(400).json({ error: "Folder path and name are required" }); } sshConn.lastActive = Date.now(); const fullPath = folderPath.endsWith("/") ? folderPath + folderName : folderPath + "/" + folderName; fileLogger.info("Creating directory", { operation: "file_mkdir", sessionId, userId, path: fullPath, }); const escapedPath = fullPath.replace(/'/g, "'\"'\"'"); const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(createCommand, (err, stream) => { if (err) { fileLogger.error("SSH createFolder error:", err); if (!res.headersSent) { return res.status(500).json({ error: err.message }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); if (chunk.toString().includes("Permission denied")) { fileLogger.error(`Permission denied creating folder: ${fullPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`, }); } return; } }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { fileLogger.success("Directory created successfully", { operation: "file_mkdir_success", sessionId, userId, path: fullPath, }); if (!res.headersSent) { res.json({ message: "Folder created successfully", path: fullPath, toast: { type: "success", message: `Folder created: ${fullPath}` }, }); } return; } if (code !== 0) { fileLogger.error( `SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ error: `Command failed: ${errorData}`, toast: { type: "error", message: `Folder creation failed: ${errorData}`, }, }); } return; } fileLogger.success("Directory created successfully", { operation: "file_mkdir_success", sessionId, userId, path: fullPath, }); if (!res.headersSent) { res.json({ message: "Folder created successfully", path: fullPath, toast: { type: "success", message: `Folder created: ${fullPath}` }, }); } }); stream.on("error", (streamErr) => { fileLogger.error("SSH createFolder stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/deleteItem: * delete: * summary: Delete a file or directory * description: Deletes a file or directory on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * isDirectory: * type: boolean * responses: * 200: * description: Item deleted successfully. * 400: * description: Missing required parameters or SSH connection not established. * 403: * description: Permission denied. * 500: * description: Failed to delete item. */ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { const { sessionId, path: itemPath, isDirectory } = req.body; const sshConn = sshSessions[sessionId]; const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!itemPath) { return res.status(400).json({ error: "Item path is required" }); } fileLogger.info("Deleting item", { operation: "file_delete", sessionId, userId, path: itemPath, type: isDirectory ? "directory" : "file", }); sshConn.lastActive = Date.now(); const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); const deleteCommand = isDirectory ? `rm -rf '${escapedPath}'` : `rm -f '${escapedPath}'`; const executeDelete = (useSudo: boolean): Promise => { return new Promise((resolve) => { if (useSudo && sshConn.sudoPassword) { execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then( (result) => { if ( result.code === 0 || (!result.stderr.includes("Permission denied") && !result.stdout.includes("Permission denied")) ) { res.json({ message: "Item deleted successfully", path: itemPath, toast: { type: "success", message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, }, }); } else { res.status(500).json({ error: `Delete failed: ${result.stderr || result.stdout}`, }); } resolve(); }, ); return; } sshConn.client.exec( `${deleteCommand} && echo "SUCCESS"`, (err, stream) => { if (err) { fileLogger.error("SSH deleteItem error:", err); res.status(500).json({ error: err.message }); resolve(); return; } let outputData = ""; let errorData = ""; let permissionDenied = false; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); if (chunk.toString().includes("Permission denied")) { permissionDenied = true; } }); stream.on("close", (code) => { if (permissionDenied) { if (sshConn.sudoPassword) { executeDelete(true).then(resolve); return; } fileLogger.error(`Permission denied deleting: ${itemPath}`); res.status(403).json({ error: `Permission denied: Cannot delete ${itemPath}.`, needsSudo: true, }); resolve(); return; } if (outputData.includes("SUCCESS") || code === 0) { fileLogger.success("Item deleted successfully", { operation: "file_delete_success", sessionId, userId, path: itemPath, }); res.json({ message: "Item deleted successfully", path: itemPath, toast: { type: "success", message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, }, }); } else { res.status(500).json({ error: `Command failed: ${errorData}`, }); } resolve(); }); stream.on("error", (streamErr) => { fileLogger.error("SSH deleteItem stream error:", streamErr); res .status(500) .json({ error: `Stream error: ${streamErr.message}` }); resolve(); }); }, ); }); }; await executeDelete(false); }); /** * @openapi * /ssh/file_manager/ssh/renameItem: * put: * summary: Rename a file or directory * description: Renames a file or directory on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * oldPath: * type: string * newName: * type: string * responses: * 200: * description: Item renamed successfully. * 400: * description: Missing required parameters or SSH connection not established. * 403: * description: Permission denied. * 500: * description: Failed to rename item. */ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { const { sessionId, oldPath, newName } = req.body; const sshConn = sshSessions[sessionId]; const userId = (req as AuthenticatedRequest).userId; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!oldPath || !newName) { return res .status(400) .json({ error: "Old path and new name are required" }); } sshConn.lastActive = Date.now(); const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1); const newPath = oldDir + newName; fileLogger.info("Renaming item", { operation: "file_rename", sessionId, userId, from: oldPath, to: newPath, }); const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; sshConn.client.exec(renameCommand, (err, stream) => { if (err) { fileLogger.error("SSH renameItem error:", err); if (!res.headersSent) { return res.status(500).json({ error: err.message }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); if (chunk.toString().includes("Permission denied")) { fileLogger.error(`Permission denied renaming: ${oldPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`, }); } return; } }); stream.on("close", (code) => { if (outputData.includes("SUCCESS")) { fileLogger.success("Item renamed successfully", { operation: "file_rename_success", sessionId, userId, from: oldPath, to: newPath, }); if (!res.headersSent) { res.json({ message: "Item renamed successfully", oldPath, newPath, toast: { type: "success", message: `Item renamed: ${oldPath} -> ${newPath}`, }, }); } return; } if (code !== 0) { fileLogger.error( `SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ error: `Command failed: ${errorData}`, toast: { type: "error", message: `Rename failed: ${errorData}` }, }); } return; } fileLogger.success("Item renamed successfully", { operation: "file_rename_success", sessionId, userId, from: oldPath, to: newPath, }); if (!res.headersSent) { res.json({ message: "Item renamed successfully", oldPath, newPath, toast: { type: "success", message: `Item renamed: ${oldPath} -> ${newPath}`, }, }); } }); stream.on("error", (streamErr) => { fileLogger.error("SSH renameItem stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/moveItem: * put: * summary: Move a file or directory * description: Moves a file or directory on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * oldPath: * type: string * newPath: * type: string * responses: * 200: * description: Item moved successfully. * 400: * description: Missing required parameters or SSH connection not established. * 403: * description: Permission denied. * 408: * description: Move operation timed out. * 500: * description: Failed to move item. */ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { const { sessionId, oldPath, newPath } = req.body; const sshConn = sshSessions[sessionId]; if (!sessionId) { return res.status(400).json({ error: "Session ID is required" }); } if (!sshConn?.isConnected) { return res.status(400).json({ error: "SSH connection not established" }); } if (!oldPath || !newPath) { return res .status(400) .json({ error: "Old path and new path are required" }); } sshConn.lastActive = Date.now(); const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'"); const escapedNewPath = newPath.replace(/'/g, "'\"'\"'"); const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; const commandTimeout = setTimeout(() => { if (!res.headersSent) { res.status(408).json({ error: "Move operation timed out. SSH connection may be unstable.", toast: { type: "error", message: "Move operation timed out. SSH connection may be unstable.", }, }); } }, 60000); sshConn.client.exec(moveCommand, (err, stream) => { if (err) { clearTimeout(commandTimeout); fileLogger.error("SSH moveItem error:", err); if (!res.headersSent) { return res.status(500).json({ error: err.message }); } return; } let outputData = ""; let errorData = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (chunk: Buffer) => { errorData += chunk.toString(); if (chunk.toString().includes("Permission denied")) { fileLogger.error(`Permission denied moving: ${oldPath}`); if (!res.headersSent) { return res.status(403).json({ error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`, toast: { type: "error", message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`, }, }); } return; } }); stream.on("close", (code) => { clearTimeout(commandTimeout); if (outputData.includes("SUCCESS")) { if (!res.headersSent) { res.json({ message: "Item moved successfully", oldPath, newPath, toast: { type: "success", message: `Item moved: ${oldPath} -> ${newPath}`, }, }); } return; } if (code !== 0) { fileLogger.error( `SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); if (!res.headersSent) { return res.status(500).json({ error: `Command failed: ${errorData}`, toast: { type: "error", message: `Move failed: ${errorData}` }, }); } return; } if (!res.headersSent) { res.json({ message: "Item moved successfully", oldPath, newPath, toast: { type: "success", message: `Item moved: ${oldPath} -> ${newPath}`, }, }); } }); stream.on("error", (streamErr) => { clearTimeout(commandTimeout); fileLogger.error("SSH moveItem stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/downloadFile: * post: * summary: Download a file * description: Downloads a file from the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * hostId: * type: integer * userId: * type: string * responses: * 200: * description: The file content. * 400: * description: Missing required parameters or file too large. * 500: * description: Failed to download file. */ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { const { sessionId, path: filePath, hostId, userId } = req.body; const downloadStartTime = Date.now(); if (!sessionId || !filePath) { fileLogger.warn("Missing download parameters", { operation: "file_download", sessionId, hasFilePath: !!filePath, }); return res.status(400).json({ error: "Missing download parameters" }); } fileLogger.info("File download started", { operation: "file_download_start", sessionId, userId, path: filePath, }); const sshConn = sshSessions[sessionId]; if (!sshConn || !sshConn.isConnected) { fileLogger.warn("SSH session not found or not connected for download", { operation: "file_download", sessionId, isConnected: sshConn?.isConnected, }); return res .status(400) .json({ error: "SSH session not found or not connected" }); } sshConn.lastActive = Date.now(); scheduleSessionCleanup(sessionId); fileLogger.info("Opening SFTP channel", { operation: "file_sftp_open", sessionId, userId, path: filePath, }); getSessionSftp(sshConn) .then((sftp) => { sftp.stat(filePath, (statErr, stats) => { if (statErr) { fileLogger.error("File stat failed for download:", statErr); return res .status(500) .json({ error: `Cannot access file: ${statErr.message}` }); } if (!stats.isFile()) { fileLogger.warn("Attempted to download non-file", { operation: "file_download", sessionId, filePath, isFile: stats.isFile(), isDirectory: stats.isDirectory(), }); return res .status(400) .json({ error: "Cannot download directories or special files" }); } const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; if (stats.size > MAX_FILE_SIZE) { fileLogger.warn("File too large for download", { operation: "file_download", sessionId, filePath, fileSize: stats.size, maxSize: MAX_FILE_SIZE, }); return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`, }); } sftp.readFile(filePath, (readErr, data) => { if (readErr) { fileLogger.error("File read failed for download:", readErr); return res .status(500) .json({ error: `Failed to read file: ${readErr.message}` }); } const base64Content = data.toString("base64"); const fileName = filePath.split("/").pop() || "download"; fileLogger.success("File download completed", { operation: "file_download_complete", sessionId, userId, hostId, path: filePath, bytes: stats.size, duration: Date.now() - downloadStartTime, }); res.json({ content: base64Content, fileName: fileName, size: stats.size, mimeType: getMimeType(fileName), path: filePath, }); }); }); }) .catch((err) => { fileLogger.error("SFTP connection failed for download:", err); return res.status(500).json({ error: "SFTP connection failed" }); }); }); /** * @openapi * /ssh/file_manager/ssh/copyItem: * post: * summary: Copy a file or directory * description: Copies a file or directory on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * sourcePath: * type: string * targetDir: * type: string * hostId: * type: integer * userId: * type: string * responses: * 200: * description: Item copied successfully. * 400: * description: Missing required parameters or SSH connection not established. * 500: * description: Failed to copy item. */ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; if (!sessionId || !sourcePath || !targetDir) { return res.status(400).json({ error: "Missing required parameters" }); } const sshConn = sshSessions[sessionId]; if (!sshConn || !sshConn.isConnected) { return res .status(400) .json({ error: "SSH session not found or not connected" }); } sshConn.lastActive = Date.now(); scheduleSessionCleanup(sessionId); const sourceName = sourcePath.split("/").pop() || "copied_item"; const timestamp = Date.now().toString().slice(-8); const uniqueName = `${sourceName}_copy_${timestamp}`; const targetPath = `${targetDir}/${uniqueName}`; const escapedSource = sourcePath.replace(/'/g, "'\"'\"'"); const escapedTarget = targetPath.replace(/'/g, "'\"'\"'"); const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`; const commandTimeout = setTimeout(() => { fileLogger.error("Copy command timed out after 60 seconds", { sourcePath, targetPath, command: copyCommand, }); if (!res.headersSent) { res.status(500).json({ error: "Copy operation timed out", toast: { type: "error", message: "Copy operation timed out. SSH connection may be unstable.", }, }); } }, 60000); sshConn.client.exec(copyCommand, (err, stream) => { if (err) { clearTimeout(commandTimeout); fileLogger.error("SSH copyItem error:", err); if (!res.headersSent) { return res.status(500).json({ error: err.message }); } return; } let errorData = ""; let stdoutData = ""; stream.on("data", (data: Buffer) => { const output = data.toString(); stdoutData += output; stream.stderr.on("data", (data: Buffer) => { const output = data.toString(); errorData += output; }); stream.on("close", (code) => { clearTimeout(commandTimeout); if (code !== 0) { const fullErrorInfo = errorData || stdoutData || "No error message available"; fileLogger.error(`SSH copyItem command failed with code ${code}`, { operation: "file_copy_failed", sessionId, sourcePath, targetPath, command: copyCommand, exitCode: code, errorData, stdoutData, fullErrorInfo, }); if (!res.headersSent) { return res.status(500).json({ error: `Copy failed: ${fullErrorInfo}`, toast: { type: "error", message: `Copy failed: ${fullErrorInfo}`, }, debug: { sourcePath, targetPath, exitCode: code, command: copyCommand, }, }); } return; } const copySuccessful = stdoutData.includes("COPY_SUCCESS") || code === 0; if (copySuccessful) { fileLogger.success("Item copied successfully", { operation: "file_copy", sessionId, sourcePath, targetPath, uniqueName, hostId, userId, }); if (!res.headersSent) { res.json({ message: "Item copied successfully", sourcePath, targetPath, uniqueName, toast: { type: "success", message: `Successfully copied to: ${uniqueName}`, }, }); } } else { fileLogger.warn("Copy completed but without success confirmation", { operation: "file_copy_uncertain", sessionId, sourcePath, targetPath, code, stdoutData: stdoutData.substring(0, 200), }); if (!res.headersSent) { res.json({ message: "Copy may have completed", sourcePath, targetPath, uniqueName, toast: { type: "warning", message: `Copy completed but verification uncertain for: ${uniqueName}`, }, }); } } }); stream.on("error", (streamErr) => { clearTimeout(commandTimeout); fileLogger.error("SSH copyItem stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: `Stream error: ${streamErr.message}` }); } }); }); }); }); /** * @openapi * /ssh/file_manager/ssh/executeFile: * post: * summary: Execute a file * description: Executes a file on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * filePath: * type: string * responses: * 200: * description: File execution result. * 400: * description: Missing required parameters or SSH connection not available. * 500: * description: Failed to execute file. */ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { const { sessionId, filePath } = req.body; const sshConn = sshSessions[sessionId]; if (!sshConn || !sshConn.isConnected) { fileLogger.error( "SSH connection not found or not connected for executeFile", { operation: "execute_file", sessionId, hasConnection: !!sshConn, isConnected: sshConn?.isConnected, }, ); return res.status(400).json({ error: "SSH connection not available" }); } if (!filePath) { return res.status(400).json({ error: "File path is required" }); } const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; sshConn.client.exec(checkCommand, (checkErr, checkStream) => { if (checkErr) { fileLogger.error("SSH executeFile check error:", checkErr); return res .status(500) .json({ error: "Failed to check file executability" }); } let checkResult = ""; checkStream.on("data", (data) => { checkResult += data.toString(); }); checkStream.on("close", () => { if (!checkResult.includes("EXECUTABLE")) { return res.status(400).json({ error: "File is not executable" }); } const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; sshConn.client.exec(executeCommand, (err, stream) => { if (err) { fileLogger.error("SSH executeFile error:", err); return res.status(500).json({ error: "Failed to execute file" }); } let output = ""; let errorOutput = ""; stream.on("data", (data) => { output += data.toString(); }); stream.stderr.on("data", (data) => { errorOutput += data.toString(); }); stream.on("close", (code) => { const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/); const actualExitCode = exitCodeMatch ? parseInt(exitCodeMatch[1]) : code; const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim(); fileLogger.info("File execution completed", { operation: "execute_file", sessionId, filePath, exitCode: actualExitCode, outputLength: cleanOutput.length, errorLength: errorOutput.length, }); res.json({ success: true, exitCode: actualExitCode, output: cleanOutput, error: errorOutput, timestamp: new Date().toISOString(), }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH executeFile stream error:", streamErr); if (!res.headersSent) { res.status(500).json({ error: "Execution stream error" }); } }); }); }); }); }); /** * @openapi * /ssh/file_manager/ssh/changePermissions: * post: * summary: Change file permissions * description: Changes the permissions of a file on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * path: * type: string * permissions: * type: string * responses: * 200: * description: Permissions changed successfully. * 400: * description: Missing required parameters or SSH connection not available. * 408: * description: Permission change timed out. * 500: * description: Failed to change permissions. */ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { const { sessionId, path, permissions } = req.body; const sshConn = sshSessions[sessionId]; if (!sshConn || !sshConn.isConnected) { fileLogger.error( "SSH connection not found or not connected for changePermissions", { operation: "change_permissions", sessionId, hasConnection: !!sshConn, isConnected: sshConn?.isConnected, }, ); return res.status(400).json({ error: "SSH connection not available" }); } if (!path) { return res.status(400).json({ error: "File path is required" }); } if (!permissions || !/^\d{3,4}$/.test(permissions)) { return res.status(400).json({ error: "Valid permissions required (e.g., 755, 644)", }); } sshConn.lastActive = Date.now(); scheduleSessionCleanup(sessionId); const octalPerms = permissions.slice(-3); const escapedPath = path.replace(/'/g, "'\"'\"'"); const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`; fileLogger.info("Changing file permissions", { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); const commandTimeout = setTimeout(() => { if (!res.headersSent) { fileLogger.error("changePermissions command timeout", { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); res.status(408).json({ error: "Permission change timed out. SSH connection may be unstable.", }); } }, 10000); sshConn.client.exec(command, (err, stream) => { if (err) { clearTimeout(commandTimeout); fileLogger.error("SSH changePermissions exec error:", err, { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); if (!res.headersSent) { return res.status(500).json({ error: "Failed to change permissions" }); } return; } let outputData = ""; let errorOutput = ""; stream.on("data", (chunk: Buffer) => { outputData += chunk.toString(); }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); stream.on("close", (code) => { clearTimeout(commandTimeout); if (outputData.includes("SUCCESS")) { fileLogger.success("File permissions changed successfully", { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); if (!res.headersSent) { res.json({ success: true, message: "Permissions changed successfully", }); } return; } if (code !== 0) { fileLogger.error("chmod command failed", { operation: "change_permissions", sessionId, path, permissions: octalPerms, exitCode: code, error: errorOutput, }); if (!res.headersSent) { return res.status(500).json({ error: errorOutput || "Failed to change permissions", }); } return; } fileLogger.success("File permissions changed successfully", { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); if (!res.headersSent) { res.json({ success: true, message: "Permissions changed successfully", }); } }); stream.on("error", (streamErr) => { clearTimeout(commandTimeout); fileLogger.error("SSH changePermissions stream error:", streamErr, { operation: "change_permissions", sessionId, path, permissions: octalPerms, }); if (!res.headersSent) { res .status(500) .json({ error: "Stream error while changing permissions" }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/extractArchive: * post: * summary: Extract archive file * description: Extracts an archive file (.tar, .tar.gz, .tgz, .zip, .tar.bz2, .tbz2, .tar.xz, .txz) to a specified or default location on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - sessionId * - archivePath * properties: * sessionId: * type: string * description: SSH session ID * archivePath: * type: string * description: Path to the archive file on remote host * extractPath: * type: string * description: Optional custom extraction path (defaults to same directory as archive) * responses: * 200: * description: Archive extracted successfully. * content: * application/json: * schema: * type: object * properties: * status: * type: string * example: success * message: * type: string * extractPath: * type: string * 400: * description: Missing required parameters, SSH connection not established, or unsupported archive format. * 403: * description: Permission denied. * 500: * description: Failed to extract archive. */ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { const { sessionId, archivePath, extractPath } = req.body; if (!sessionId || !archivePath) { return res.status(400).json({ error: "Missing required parameters" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not connected" }); } session.lastActive = Date.now(); scheduleSessionCleanup(sessionId); const fileName = archivePath.split("/").pop() || ""; const fileExt = fileName.toLowerCase(); let extractCommand = ""; const targetPath = extractPath || archivePath.substring(0, archivePath.lastIndexOf("/")); if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) { extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`; } else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) { extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`; } else if (fileExt.endsWith(".tar.xz")) { extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`; } else if (fileExt.endsWith(".tar")) { extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`; } else if (fileExt.endsWith(".zip")) { extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`; } else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) { extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`; } else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) { extractCommand = `bunzip2 -k "${archivePath}"`; } else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) { extractCommand = `unxz -k "${archivePath}"`; } else if (fileExt.endsWith(".7z")) { extractCommand = `7z x "${archivePath}" -o"${targetPath}"`; } else if (fileExt.endsWith(".rar")) { extractCommand = `unrar x "${archivePath}" "${targetPath}/"`; } else { return res.status(400).json({ error: "Unsupported archive format" }); } fileLogger.info("Extracting archive", { operation: "extract_archive", sessionId, archivePath, extractPath: targetPath, command: extractCommand, }); session.client.exec(extractCommand, (err, stream) => { if (err) { fileLogger.error("SSH exec error during extract:", err, { operation: "extract_archive", sessionId, archivePath, }); return res .status(500) .json({ error: "Failed to execute extract command" }); } let errorOutput = ""; stream.on("data", () => { /* consume stdout */ }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); stream.on("close", (code: number) => { if (code !== 0) { fileLogger.error("Extract command failed", { operation: "extract_archive", sessionId, archivePath, exitCode: code, error: errorOutput, }); let friendlyError = errorOutput || "Failed to extract archive"; if ( errorOutput.includes("command not found") || errorOutput.includes("not found") ) { let missingCmd = ""; let installHint = ""; if (fileExt.endsWith(".zip")) { missingCmd = "unzip"; installHint = "apt install unzip / yum install unzip / brew install unzip"; } else if ( fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz") || fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2") || fileExt.endsWith(".tar.xz") || fileExt.endsWith(".tar") ) { missingCmd = "tar"; installHint = "Usually pre-installed on Linux/Unix systems"; } else if (fileExt.endsWith(".gz")) { missingCmd = "gunzip"; installHint = "apt install gzip / yum install gzip / Usually pre-installed"; } else if (fileExt.endsWith(".bz2")) { missingCmd = "bunzip2"; installHint = "apt install bzip2 / yum install bzip2 / brew install bzip2"; } else if (fileExt.endsWith(".xz")) { missingCmd = "unxz"; installHint = "apt install xz-utils / yum install xz / brew install xz"; } else if (fileExt.endsWith(".7z")) { missingCmd = "7z"; installHint = "apt install p7zip-full / yum install p7zip / brew install p7zip"; } else if (fileExt.endsWith(".rar")) { missingCmd = "unrar"; installHint = "apt install unrar / yum install unrar / brew install unrar"; } if (missingCmd) { friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`; } } return res.status(500).json({ error: friendlyError }); } fileLogger.success("Archive extracted successfully", { operation: "extract_archive", sessionId, archivePath, extractPath: targetPath, }); res.json({ success: true, message: "Archive extracted successfully", extractPath: targetPath, }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH extractArchive stream error:", streamErr, { operation: "extract_archive", sessionId, archivePath, }); if (!res.headersSent) { res .status(500) .json({ error: "Stream error while extracting archive" }); } }); }); }); /** * @openapi * /ssh/file_manager/ssh/compressFiles: * post: * summary: Compress files * description: Compresses files and/or directories on the remote host. * tags: * - File Manager * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * paths: * type: array * items: * type: string * archiveName: * type: string * format: * type: string * responses: * 200: * description: Files compressed successfully. * 400: * description: Missing required parameters or unsupported compression format. * 500: * description: Failed to compress files. */ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { const { sessionId, paths, archiveName, format } = req.body; if ( !sessionId || !paths || !Array.isArray(paths) || paths.length === 0 || !archiveName ) { return res.status(400).json({ error: "Missing required parameters" }); } const session = sshSessions[sessionId]; if (!session || !session.isConnected) { return res.status(400).json({ error: "SSH session not connected" }); } session.lastActive = Date.now(); scheduleSessionCleanup(sessionId); const compressionFormat = format || "zip"; let compressCommand = ""; const firstPath = paths[0]; const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/"; const fileNames = paths .map((p) => { const name = p.split("/").pop(); return `"${name}"`; }) .join(" "); let archivePath = ""; if (archiveName.includes("/")) { archivePath = archiveName; } else { archivePath = workingDir.endsWith("/") ? `${workingDir}${archiveName}` : `${workingDir}/${archiveName}`; } if (compressionFormat === "zip") { compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`; } else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") { compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`; } else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") { compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`; } else if (compressionFormat === "tar.xz") { compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`; } else if (compressionFormat === "tar") { compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`; } else if (compressionFormat === "7z") { compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`; } else { return res.status(400).json({ error: "Unsupported compression format" }); } fileLogger.info("Compressing files", { operation: "compress_files", sessionId, paths, archivePath, format: compressionFormat, command: compressCommand, }); session.client.exec(compressCommand, (err, stream) => { if (err) { fileLogger.error("SSH exec error during compress:", err, { operation: "compress_files", sessionId, paths, }); return res .status(500) .json({ error: "Failed to execute compress command" }); } let errorOutput = ""; stream.on("data", () => { /* consume stdout */ }); stream.stderr.on("data", (data: Buffer) => { errorOutput += data.toString(); }); stream.on("close", (code: number) => { if (code !== 0) { fileLogger.error("Compress command failed", { operation: "compress_files", sessionId, paths, archivePath, exitCode: code, error: errorOutput, }); let friendlyError = errorOutput || "Failed to compress files"; if ( errorOutput.includes("command not found") || errorOutput.includes("not found") ) { const commandMap: Record = { zip: { cmd: "zip", install: "apt install zip / yum install zip / brew install zip", }, "tar.gz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems", }, "tar.bz2": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems", }, "tar.xz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems", }, tar: { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems", }, "7z": { cmd: "7z", install: "apt install p7zip-full / yum install p7zip / brew install p7zip", }, }; const info = commandMap[compressionFormat]; if (info) { friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`; } } return res.status(500).json({ error: friendlyError }); } fileLogger.success("Files compressed successfully", { operation: "compress_files", sessionId, paths, archivePath, format: compressionFormat, }); res.json({ success: true, message: "Files compressed successfully", archivePath: archivePath, }); }); stream.on("error", (streamErr) => { fileLogger.error("SSH compressFiles stream error:", streamErr, { operation: "compress_files", sessionId, paths, }); if (!res.headersSent) { res.status(500).json({ error: "Stream error while compressing files" }); } }); }); }); process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); }); process.on("SIGTERM", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); }); const PORT = 30004; try { const server = app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { fileLogger.error("Failed to initialize AuthManager", err, { operation: "auth_init_error", }); } }); server.on("error", (err) => { fileLogger.error("File Manager server error", err, { operation: "file_manager_server_error", port: PORT, }); }); } catch (err) { fileLogger.error("Failed to start File Manager server", err, { operation: "file_manager_server_start_failed", port: PORT, }); } ================================================ FILE: src/backend/ssh/host-key-verifier.ts ================================================ import type { WebSocket } from "ws"; import { db } from "../database/db/index.js"; import { hosts } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { sshLogger } from "../utils/logger.js"; interface HostKeyVerificationData { scenario: "new" | "changed"; ip: string; port: number; hostname?: string; fingerprint: string; oldFingerprint?: string; keyType: string; oldKeyType?: string; algorithm: string; } interface VerificationResponse { action: "accept" | "reject"; } export class SSHHostKeyVerifier { static async createHostVerifier( hostId: number | null, ip: string, port: number, ws: WebSocket | null, userId: string, isJumpHost: boolean = false, ): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> { return (hostkey: Buffer, verify: (valid: boolean) => void): void => { (async () => { try { const fingerprint = hostkey.toString("hex"); const keyType = this.getKeyType(hostkey); const algorithm = "sha256"; if (!hostId) { sshLogger.info( "Host key verification skipped (no hostId - quick connect)", { operation: "host_key_skip", ip, port, fingerprint, keyType, userId, }, ); verify(true); return; } const host = await db.query.hosts.findFirst({ where: eq(hosts.id, hostId), }); if (!host) { sshLogger.warn( "Host not found in database during key verification", { operation: "host_key_no_host", hostId, ip, port, userId, }, ); verify(true); return; } if (!host.hostKeyFingerprint) { if (isJumpHost) { await this.storeHostKey(hostId, fingerprint, keyType, algorithm); sshLogger.info("Jump host key auto-accepted and stored", { operation: "host_key_stored", hostId, ip, port, fingerprint, keyType, userId, isJumpHost: true, }); verify(true); return; } if (!ws) { sshLogger.warn( "No WebSocket available for host key verification prompt", { operation: "host_key_no_ws", hostId, ip, port, userId, }, ); verify(true); return; } const accepted = await this.promptUserForNewKey( ws, ip, port, host.name || undefined, fingerprint, keyType, algorithm, ); if (accepted) { await this.storeHostKey(hostId, fingerprint, keyType, algorithm); sshLogger.info("New host key accepted by user and stored", { operation: "host_key_stored", hostId, ip, port, fingerprint, keyType, userId, }); } else { sshLogger.warn("User rejected new host key", { operation: "host_key_rejected", hostId, ip, port, fingerprint, keyType, userId, }); } verify(accepted); return; } if (host.hostKeyFingerprint === fingerprint) { await db .update(hosts) .set({ hostKeyLastVerified: new Date().toISOString(), }) .where(eq(hosts.id, hostId)); sshLogger.info("Host key verified successfully", { operation: "host_key_verified", hostId, ip, port, fingerprint, keyType, userId, }); verify(true); return; } sshLogger.error("Host key mismatch detected - SECURITY WARNING", { operation: "host_key_mismatch", hostId, ip, port, oldFingerprint: host.hostKeyFingerprint, newFingerprint: fingerprint, oldKeyType: host.hostKeyType, newKeyType: keyType, userId, changeCount: host.hostKeyChangedCount || 0, }); if (isJumpHost) { await this.updateHostKey( hostId, fingerprint, keyType, algorithm, host.hostKeyChangedCount || 0, ); sshLogger.warn("Jump host key changed - auto-accepted", { operation: "host_key_updated", hostId, ip, port, fingerprint, keyType, userId, isJumpHost: true, }); verify(true); return; } if (!ws) { sshLogger.error( "Host key changed - please connect via Terminal to verify the new key", { operation: "host_key_no_ws_reject", hostId, ip, port, userId, message: "SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.", }, ); verify(false); return; } const accepted = await this.promptUserForChangedKey( ws, ip, port, host.name || undefined, fingerprint, host.hostKeyFingerprint, keyType, host.hostKeyType || "unknown", algorithm, ); if (accepted) { await this.updateHostKey( hostId, fingerprint, keyType, algorithm, host.hostKeyChangedCount || 0, ); sshLogger.warn("Changed host key accepted by user", { operation: "host_key_updated", hostId, ip, port, oldFingerprint: host.hostKeyFingerprint, newFingerprint: fingerprint, userId, changeCount: (host.hostKeyChangedCount || 0) + 1, }); } else { sshLogger.error("User rejected changed host key", { operation: "host_key_change_rejected", hostId, ip, port, userId, }); } verify(accepted); } catch (error) { sshLogger.error("Error in host key verification", error, { operation: "host_key_error", hostId, ip, port, userId, }); verify(false); } })(); }; } private static async storeHostKey( hostId: number, fingerprint: string, keyType: string, algorithm: string, ): Promise { await db .update(hosts) .set({ hostKeyFingerprint: fingerprint, hostKeyType: keyType, hostKeyAlgorithm: algorithm, hostKeyFirstSeen: new Date().toISOString(), hostKeyLastVerified: new Date().toISOString(), }) .where(eq(hosts.id, hostId)); } private static async updateHostKey( hostId: number, fingerprint: string, keyType: string, algorithm: string, currentChangeCount: number, ): Promise { await db .update(hosts) .set({ hostKeyFingerprint: fingerprint, hostKeyType: keyType, hostKeyAlgorithm: algorithm, hostKeyLastVerified: new Date().toISOString(), hostKeyChangedCount: currentChangeCount + 1, }) .where(eq(hosts.id, hostId)); } private static async promptUserForNewKey( ws: WebSocket, ip: string, port: number, hostname: string | undefined, fingerprint: string, keyType: string, algorithm: string, ): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { ws.removeListener("message", messageHandler); sshLogger.warn("Host key verification timeout (new key)", { operation: "host_key_timeout", ip, port, }); resolve(false); }, 60000); const messageHandler = (data: Buffer) => { try { const message = JSON.parse(data.toString()); if (message.type === "host_key_verification_response") { clearTimeout(timeout); ws.removeListener("message", messageHandler); const response = message.data as VerificationResponse; resolve(response.action === "accept"); } } catch (error) { sshLogger.error( "Error parsing host key verification response", error, ); } }; ws.on("message", messageHandler); const verificationData: HostKeyVerificationData = { scenario: "new", ip, port, hostname, fingerprint, keyType, algorithm, }; ws.send( JSON.stringify({ type: "host_key_verification_required", data: verificationData, }), ); }); } private static async promptUserForChangedKey( ws: WebSocket, ip: string, port: number, hostname: string | undefined, fingerprint: string, oldFingerprint: string, keyType: string, oldKeyType: string, algorithm: string, ): Promise { return new Promise((resolve) => { const timeout = setTimeout(() => { ws.removeListener("message", messageHandler); sshLogger.error("Host key verification timeout (changed key)", { operation: "host_key_timeout", ip, port, }); resolve(false); }, 120000); const messageHandler = (data: Buffer) => { try { const message = JSON.parse(data.toString()); if (message.type === "host_key_verification_response") { clearTimeout(timeout); ws.removeListener("message", messageHandler); const response = message.data as VerificationResponse; resolve(response.action === "accept"); } } catch (error) { sshLogger.error( "Error parsing host key verification response", error, ); } }; ws.on("message", messageHandler); const verificationData: HostKeyVerificationData = { scenario: "changed", ip, port, hostname, fingerprint, oldFingerprint, keyType, oldKeyType, algorithm, }; ws.send( JSON.stringify({ type: "host_key_changed", data: verificationData, }), ); }); } private static getKeyType(key: Buffer): string { try { if (key.length < 4) { return "unknown"; } const typeLength = key.readUInt32BE(0); if (typeLength > key.length - 4 || typeLength > 256) { return "unknown"; } const keyType = key.toString("utf8", 4, 4 + typeLength); if ( (keyType && keyType.startsWith("ssh-")) || keyType.startsWith("ecdsa-") ) { return keyType; } return "unknown"; } catch (error) { sshLogger.error("Error parsing SSH key type", error); return "unknown"; } } } ================================================ FILE: src/backend/ssh/opkssh-auth.ts ================================================ import { spawn, ChildProcess } from "child_process"; import { randomUUID } from "crypto"; import { WebSocket } from "ws"; import { IncomingMessage } from "http"; import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; import { sshLogger } from "../utils/logger.js"; import { getDb } from "../database/db/index.js"; import { opksshTokens } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { UserCrypto } from "../utils/user-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js"; import { promises as fs } from "fs"; import path from "path"; import axios from "axios"; import { getRequestOrigin } from "../utils/request-origin.js"; const AUTH_TIMEOUT = 60 * 1000; interface OPKSSHAuthSession { requestId: string; userId: string; hostId: number; hostname: string; process: ChildProcess; localPort: number; callbackPort: number; remoteRedirectUri: string; status: | "starting" | "waiting_for_auth" | "authenticating" | "completed" | "error"; ws: WebSocket; stdoutBuffer: string; privateKeyBuffer: string; sshCertBuffer: string; identity: { email?: string; sub?: string; issuer?: string; audience?: string; }; createdAt: Date; approvalTimeout: NodeJS.Timeout; cleanup: () => Promise; } const activeAuthSessions = new Map(); const cleanupInProgress = new Set(); function getOPKConfigPath(): string { const dataDir = process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); return path.join(dataDir, ".opk", "config.yml"); } async function ensureOPKConfigDir(): Promise { const configPath = getOPKConfigPath(); const configDir = path.dirname(configPath); await fs.mkdir(configDir, { recursive: true }); } async function createTemplateConfig(): Promise { const configPath = getOPKConfigPath(); const template = ` # OPKSSH Configuration # OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md # Termix Documentation: https://docs.termix.site/opkssh `; try { await ensureOPKConfigDir(); await fs.writeFile(configPath, template, "utf8"); sshLogger.info(`Created template OPKSSH config at ${configPath}`); } catch (error) { sshLogger.warn("Failed to create template OPKSSH config", error); } } async function checkOPKConfigExists(): Promise<{ exists: boolean; error?: string; configPath?: string; }> { const configPath = getOPKConfigPath(); const isDocker = !!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app"); const dockerHint = isDocker ? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user." : ""; try { const content = await fs.readFile(configPath, "utf8"); if (!content.includes("providers:")) { return { exists: false, configPath, error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`, }; } const lines = content.split("\n"); const hasUncommentedProvider = lines.some((line) => { const trimmed = line.trim(); return ( trimmed.startsWith("- alias:") || (trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#")) ); }); if (!hasUncommentedProvider) { return { exists: false, configPath, error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`, }; } if (!content.includes("redirect_uris:")) { return { exists: false, configPath, error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`, }; } return { exists: true, configPath }; } catch { await createTemplateConfig(); return { exists: false, configPath, error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`, }; } } export async function startOPKSSHAuth( userId: string, hostId: number, hostname: string, ws: WebSocket, requestOrigin: string, ): Promise { try { await ensureOPKConfigDir(); const configDir = path.dirname(getOPKConfigPath()); await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK); } catch (error) { sshLogger.error("OPKSSH directory not accessible", error); const isDocker = !!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app"); const dockerHint = isDocker ? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user." : ""; ws.send( JSON.stringify({ type: "opkssh_error", error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`, }), ); return ""; } const configCheck = await checkOPKConfigExists(); if (!configCheck.exists) { ws.send( JSON.stringify({ type: "opkssh_config_error", requestId: "", error: configCheck.error, instructions: configCheck.error, }), ); return ""; } const requestId = randomUUID(); const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`; const session: Partial = { requestId, userId, hostId, hostname, localPort: 0, callbackPort: 0, remoteRedirectUri, status: "starting", ws, stdoutBuffer: "", privateKeyBuffer: "", sshCertBuffer: "", identity: {}, createdAt: new Date(), }; try { const binaryPath = OPKSSHBinaryManager.getBinaryPath(); const configPath = getOPKConfigPath(); const args = [ "login", "--print-key", "--disable-browser-open", `--config-path=${configPath}`, `--remote-redirect-uri=${remoteRedirectUri}`, ]; const opksshProcess = spawn(binaryPath, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, }, }); session.process = opksshProcess; const cleanup = async () => { await cleanupAuthSession(requestId); }; session.cleanup = cleanup; const timeout = setTimeout(async () => { sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`); ws.send( JSON.stringify({ type: "opkssh_timeout", requestId, }), ); await cleanup(); }, AUTH_TIMEOUT); session.approvalTimeout = timeout; ws.on("close", () => { cleanup(); }); activeAuthSessions.set(requestId, session as OPKSSHAuthSession); opksshProcess.stdout?.on("data", (data) => { const output = data.toString(); handleOPKSSHOutput(requestId, output); }); opksshProcess.stderr?.on("data", async (data) => { const stderr = data.toString(); if ( stderr.includes("Opening browser to") || stderr.includes("Open your browser to:") ) { handleOPKSSHOutput(requestId, stderr); } if (stderr.includes("listening on")) { handleOPKSSHOutput(requestId, stderr); } if (stderr.includes("provider not found") || stderr.includes("config")) { ws.send( JSON.stringify({ type: "opkssh_config_error", requestId, error: "OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.", instructions: "See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md", }), ); cleanup(); } if ( stderr.includes("level=error") || stderr.includes("Error:") || stderr.includes("failed") ) { const isXdgOpenError = stderr.includes('exec: "xdg-open"'); if (!isXdgOpenError) { if ( stderr.includes("bind: address already in use") || stderr.includes("error logging in") || stderr.includes("failed to start") ) { await cleanup(); } } } }); opksshProcess.on("error", (error) => { ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: `OPKSSH process error: ${error.message}`, }), ); cleanup(); }); opksshProcess.on("exit", (code) => { if (code !== 0 && session.status !== "completed") { ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: `OPKSSH process exited with code ${code}`, }), ); } cleanup(); }); return requestId; } catch (error) { sshLogger.error(`Failed to start OPKSSH auth session`, error); ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : "Unknown error"}`, }), ); return ""; } } function handleOPKSSHOutput(requestId: string, output: string): void { const session = activeAuthSessions.get(requestId); if (!session) { return; } session.stdoutBuffer += output; const chooserUrlMatch = session.stdoutBuffer.match( /(?:Opening browser to|Open your browser to:)\s*http:\/\/localhost:(\d+)\/chooser/, ); if (chooserUrlMatch && session.status === "starting") { const actualPort = parseInt(chooserUrlMatch[1], 10); const localChooserUrl = `http://localhost:${actualPort}/chooser`; session.localPort = actualPort; const baseUrl = session.remoteRedirectUri.replace( /\/ssh\/opkssh-callback$/, "", ); const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`; session.status = "waiting_for_auth"; session.ws.send( JSON.stringify({ type: "opkssh_status", requestId, stage: "chooser", url: proxiedChooserUrl, localUrl: localChooserUrl, message: "Please authenticate in your browser", }), ); } const callbackPortMatch = session.stdoutBuffer.match( /listening on http:\/\/127\.0\.0\.1:(\d+)\//, ); if (callbackPortMatch && !session.callbackPort) { session.callbackPort = parseInt(callbackPortMatch[1], 10); } if (output.includes("BEGIN OPENSSH PRIVATE KEY")) { session.status = "authenticating"; session.ws.send( JSON.stringify({ type: "opkssh_status", requestId, stage: "authenticating", message: "Processing authentication...", }), ); } const privateKeyMatch = session.stdoutBuffer.match( /(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/, ); if (privateKeyMatch) { session.privateKeyBuffer = privateKeyMatch[1].trim(); } const certMatch = session.stdoutBuffer.match( /(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/, ); if (certMatch) { session.sshCertBuffer = certMatch[1].trim(); } const identityMatch = session.stdoutBuffer.match( /Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/, ); if (identityMatch) { session.identity = { email: identityMatch[1], sub: identityMatch[2], issuer: identityMatch[3], audience: identityMatch[4], }; } if (session.privateKeyBuffer && session.sshCertBuffer) { if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) { sshLogger.error(`Invalid private key extracted [${requestId}]`, { bufferPrefix: session.privateKeyBuffer.substring(0, 50), }); session.ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: "Failed to extract valid private key from OPKSSH output", }), ); return; } if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) { sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, { bufferPrefix: session.sshCertBuffer.substring(0, 50), }); session.ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: "Failed to extract valid SSH certificate from OPKSSH output", }), ); return; } storeOPKSSHToken(session); } } async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise { try { const db = getDb(); const userCrypto = UserCrypto.getInstance(); const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); const userDataKey = userCrypto.getUserDataKey(session.userId); if (!userDataKey) { throw new Error("User data key not found"); } const tokenId = `opkssh-${session.userId}-${session.hostId}`; const encryptedCert = FieldCrypto.encryptField( session.sshCertBuffer, userDataKey, tokenId, "ssh_cert", ); const encryptedKey = FieldCrypto.encryptField( session.privateKeyBuffer, userDataKey, tokenId, "private_key", ); await db .insert(opksshTokens) .values({ userId: session.userId, hostId: session.hostId, sshCert: encryptedCert, privateKey: encryptedKey, email: session.identity.email, sub: session.identity.sub, issuer: session.identity.issuer, audience: session.identity.audience, expiresAt: expiresAt.toISOString(), }) .onConflictDoUpdate({ target: [opksshTokens.userId, opksshTokens.hostId], set: { sshCert: encryptedCert, privateKey: encryptedKey, email: session.identity.email, sub: session.identity.sub, issuer: session.identity.issuer, audience: session.identity.audience, expiresAt: expiresAt.toISOString(), createdAt: new Date().toISOString(), }, }); session.status = "completed"; session.ws.send( JSON.stringify({ type: "opkssh_completed", requestId: session.requestId, expiresAt: expiresAt.toISOString(), }), ); try { await axios.post( "http://localhost:30006/activity/log", { type: "opkssh_authentication", hostId: session.hostId, hostName: session.hostname, status: "approved", }, { headers: { Authorization: `Bearer ${process.env.INTERNAL_AUTH_TOKEN}`, }, }, ); } catch (activityError) { sshLogger.warn("Failed to log OPKSSH activity", activityError); } await session.cleanup(); } catch (error) { sshLogger.error( `Failed to store OPKSSH token for session ${session.requestId}`, error, ); session.ws.send( JSON.stringify({ type: "opkssh_error", requestId: session.requestId, error: "Failed to store authentication token", }), ); await session.cleanup(); } } export async function getOPKSSHToken( userId: string, hostId: number, ): Promise<{ sshCert: string; privateKey: string } | null> { try { const db = getDb(); const token = await db .select() .from(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ) .limit(1); if (!token || token.length === 0) { return null; } const tokenData = token[0]; const expiresAt = new Date(tokenData.expiresAt); if (expiresAt < new Date()) { await db .delete(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ); return null; } const userCrypto = UserCrypto.getInstance(); const userDataKey = userCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error("User data key not found"); } const tokenId = `opkssh-${userId}-${hostId}`; const decryptedCert = FieldCrypto.decryptField( tokenData.sshCert, userDataKey, tokenId, "ssh_cert", ); const decryptedKey = FieldCrypto.decryptField( tokenData.privateKey, userDataKey, tokenId, "private_key", ); await db .update(opksshTokens) .set({ lastUsed: new Date().toISOString() }) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ); return { sshCert: decryptedCert, privateKey: decryptedKey, }; } catch (error) { sshLogger.error(`Failed to retrieve OPKSSH token`, error); return null; } } export async function deleteOPKSSHToken( userId: string, hostId: number, ): Promise { const db = getDb(); await db .delete(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ); } export async function invalidateOPKSSHToken( userId: string, hostId: number, reason: string, ): Promise { try { const db = getDb(); await db .delete(opksshTokens) .where( and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)), ); } catch (error) { sshLogger.error(`Failed to invalidate OPKSSH token`, { userId, hostId, reason, error, }); } } export async function handleOAuthCallback( requestId: string, queryString: string, ): Promise<{ success: boolean; message?: string }> { const session = activeAuthSessions.get(requestId); if (!session) { return { success: false, message: "Invalid authentication session" }; } try { const callbackUrl = `http://localhost:${session.localPort}/login-callback?${queryString}`; await axios.get(callbackUrl, { timeout: 10000, validateStatus: () => true, }); return { success: true }; } catch { session.ws.send( JSON.stringify({ type: "opkssh_error", requestId, error: "Failed to complete authentication", }), ); await session.cleanup(); return { success: false, message: "Authentication failed" }; } } async function cleanupAuthSession(requestId: string): Promise { if (cleanupInProgress.has(requestId)) { return; } cleanupInProgress.add(requestId); try { const session = activeAuthSessions.get(requestId); if (!session) { cleanupInProgress.delete(requestId); return; } if (session.approvalTimeout) { clearTimeout(session.approvalTimeout); } if (session.process) { try { session.process.kill("SIGTERM"); await new Promise((resolve) => { const killTimeout = setTimeout(() => { if (session.process && !session.process.killed) { session.process.kill("SIGKILL"); } resolve(); }, 3000); session.process.once("exit", () => { clearTimeout(killTimeout); resolve(); }); }); await new Promise((resolve) => setTimeout(resolve, 1000)); } catch (killError) { sshLogger.warn( `Failed to kill OPKSSH process for session ${requestId}`, killError, ); } } activeAuthSessions.delete(requestId); } finally { cleanupInProgress.delete(requestId); } } export function cancelAuthSession(requestId: string): void { const session = activeAuthSessions.get(requestId); if (session) { session.cleanup(); } } export function getActiveAuthSession( requestId: string, ): OPKSSHAuthSession | undefined { return activeAuthSessions.get(requestId); } export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] { const sessions: OPKSSHAuthSession[] = []; for (const session of activeAuthSessions.values()) { if (session.userId === userId) { sessions.push(session); } } return sessions; } export function getActiveSessionsAll(): OPKSSHAuthSession[] { return Array.from(activeAuthSessions.values()); } export async function getUserIdFromRequest(req: { cookies?: Record; headers: Record; }): Promise { try { const { AuthManager } = await import("../utils/auth-manager.js"); const authManager = AuthManager.getInstance(); const token = req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", ""); if (!token) { return null; } const decoded = await authManager.verifyJWTToken(token); return decoded?.userId || null; } catch { return null; } } ================================================ FILE: src/backend/ssh/server-stats.ts ================================================ import express from "express"; import net from "net"; import cors from "cors"; import cookieParser from "cookie-parser"; import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { hosts, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { statsLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { PermissionManager } from "../utils/permission-manager.js"; import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js"; import type { LogEntry, ConnectionStage } from "../../types/connection-log.js"; import { collectCpuMetrics } from "./widgets/cpu-collector.js"; import { collectMemoryMetrics } from "./widgets/memory-collector.js"; import { collectDiskMetrics } from "./widgets/disk-collector.js"; import { collectNetworkMetrics } from "./widgets/network-collector.js"; import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; 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 { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { connectionPool, withConnection } from "./ssh-connection-pool.js"; function supportsMetrics(host: SSHHostWithCredentials): boolean { const connectionType = host.connectionType || "ssh"; return connectionType === "ssh"; } function isTcpPingEnabled(statsConfig: StatsConfig): boolean { return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing; } function createConnectionLog( type: "info" | "success" | "warning" | "error", stage: ConnectionStage, message: string, details?: Record, ): Omit { return { type, stage, message, details, }; } interface JumpHostConfig { id: number; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; [key: string]: unknown; } async function resolveJumpHost( hostId: number, userId: string, ): Promise { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResults.length === 0) { return null; } const host = hostResults[0]; if (host.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; return { ...host, password: credential.password as string | undefined, key: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, } as JumpHostConfig; } } return host as JumpHostConfig; } catch (error) { statsLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", hostId, userId, }); return null; } } async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } let currentClient: Client | null = null; const clients: Client[] = []; try { const jumpHostConfigs: Array>> = []; for (let i = 0; i < jumpHosts.length; i++) { const config = await resolveJumpHost(jumpHosts[i].hostId, userId); jumpHostConfigs.push(config); } const totalHops = jumpHostConfigs.length; for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { statsLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, hopIndex: i, totalHops, }); clients.forEach((c) => c.end()); return null; } } let proxySocket: import("net").Socket | null = null; if (socks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]!; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, socks5Config, ); } for (let i = 0; i < jumpHostConfigs.length; i++) { const jumpHostConfig = jumpHostConfigs[i]!; const jumpClient = new Client(); clients.push(jumpClient); const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( jumpHostConfig.id, jumpHostConfig.ip, jumpHostConfig.port || 22, null, userId, true, ); const connected = await new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); jumpClient.on("ready", () => { clearTimeout(timeout); resolve(true); }); jumpClient.on("error", (err) => { clearTimeout(timeout); statsLogger.error( `Jump host ${i + 1}/${totalHops} connection failed`, err, { operation: "jump_host_connect", hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, hopIndex: i, totalHops, previousHop: i > 0 ? jumpHostConfigs[i - 1]?.ip : proxySocket ? "proxy" : "direct", usedProxySocket: i === 0 && !!proxySocket, }, ); resolve(false); }); const connectConfig: Record = { host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, readyTimeout: 30000, hostVerifier: jumpHostVerifier, }; if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { connectConfig.password = jumpHostConfig.password; } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { const cleanKey = jumpHostConfig.key .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } } if (currentClient) { currentClient.forwardOut( "127.0.0.1", 0, jumpHostConfig.ip, jumpHostConfig.port || 22, (err, stream) => { if (err) { clearTimeout(timeout); resolve(false); return; } connectConfig.sock = stream; jumpClient.connect(connectConfig); }, ); } else if (proxySocket) { connectConfig.sock = proxySocket; jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } }); if (!connected) { clients.forEach((c) => c.end()); return null; } currentClient = jumpClient; } return currentClient; } catch (error) { statsLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); clients.forEach((c) => c.end()); return null; } } interface MetricsSession { client: Client; isConnected: boolean; lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; hostId: number; userId: string; } interface PendingTOTPSession { client: Client; finish: (responses: string[]) => void; config: ConnectConfig; createdAt: number; sessionId: string; hostId: number; userId: string; prompts?: Array<{ prompt: string; echo: boolean }>; totpPromptIndex?: number; resolvedPassword?: string; totpAttempts: number; } interface MetricsViewer { sessionId: string; userId: string; hostId: number; lastHeartbeat: number; } const metricsSessions: Record = {}; const pendingTOTPSessions: Record = {}; function cleanupMetricsSession(sessionId: string) { const session = metricsSessions[sessionId]; if (session) { if (session.activeOperations > 0) { statsLogger.warn( `Deferring metrics session cleanup - ${session.activeOperations} active operations`, { operation: "cleanup_deferred", sessionId, activeOperations: session.activeOperations, }, ); scheduleMetricsSessionCleanup(sessionId); return; } try { session.client.end(); } catch { // expected } clearTimeout(session.timeout); delete metricsSessions[sessionId]; } } function scheduleMetricsSessionCleanup(sessionId: string) { const session = metricsSessions[sessionId]; if (session) { if (session.timeout) clearTimeout(session.timeout); session.timeout = setTimeout( () => { cleanupMetricsSession(sessionId); }, 30 * 60 * 1000, ); } } function getSessionKey(hostId: number, userId: string): string { return `${userId}:${hostId}`; } class RequestQueue { private queues = new Map Promise>>(); private processing = new Set(); private requestTimeout = 60000; async queueRequest(hostId: number, request: () => Promise): Promise { return new Promise((resolve, reject) => { const wrappedRequest = async () => { try { const result = await Promise.race([ request(), new Promise((_, rej) => setTimeout( () => rej( new Error( `Request timeout after ${this.requestTimeout}ms for host ${hostId}`, ), ), this.requestTimeout, ), ), ]); resolve(result); } catch (error) { reject(error); } }; const queue = this.queues.get(hostId) || []; queue.push(wrappedRequest); this.queues.set(hostId, queue); this.processQueue(hostId); }); } private async processQueue(hostId: number): Promise { if (this.processing.has(hostId)) return; this.processing.add(hostId); const queue = this.queues.get(hostId) || []; while (queue.length > 0) { const request = queue.shift(); if (request) { try { await request(); } catch { // expected } } } this.processing.delete(hostId); const currentQueue = this.queues.get(hostId); if (currentQueue && currentQueue.length > 0) { this.processQueue(hostId); } } } interface CachedMetrics { data: unknown; timestamp: number; hostId: number; } class MetricsCache { private cache = new Map(); private ttl = 30000; get(hostId: number): unknown | null { const cached = this.cache.get(hostId); if (cached && Date.now() - cached.timestamp < this.ttl) { return cached.data; } return null; } set(hostId: number, data: unknown): void { this.cache.set(hostId, { data, timestamp: Date.now(), hostId, }); } clear(hostId?: number): void { if (hostId) { this.cache.delete(hostId); } else { this.cache.clear(); } } } interface AuthFailureRecord { count: number; lastFailure: number; reason: "TOTP" | "AUTH" | "TIMEOUT"; permanent: boolean; } class AuthFailureTracker { private failures = new Map(); private maxRetries = 3; private backoffBase = 5000; recordFailure( hostId: number, reason: "TOTP" | "AUTH" | "TIMEOUT", permanent = false, ): void { const existing = this.failures.get(hostId); if (existing) { existing.count++; existing.lastFailure = Date.now(); existing.reason = reason; if (permanent) existing.permanent = true; } else { this.failures.set(hostId, { count: 1, lastFailure: Date.now(), reason, permanent, }); } } shouldSkip(hostId: number): boolean { const record = this.failures.get(hostId); if (!record) return false; if (record.reason === "TOTP" || record.permanent) { return true; } if (record.count >= this.maxRetries) { return true; } const backoffTime = this.backoffBase * Math.pow(2, record.count - 1); const timeSinceFailure = Date.now() - record.lastFailure; return timeSinceFailure < backoffTime; } getSkipReason(hostId: number): string | null { const record = this.failures.get(hostId); if (!record) return null; if (record.reason === "TOTP") { return "TOTP authentication required (metrics unavailable)"; } if (record.permanent) { return "Authentication permanently failed"; } if (record.count >= this.maxRetries) { return `Too many authentication failures (${record.count} attempts)`; } const backoffTime = this.backoffBase * Math.pow(2, record.count - 1); const timeSinceFailure = Date.now() - record.lastFailure; const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000); if (timeSinceFailure < backoffTime) { return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`; } return null; } reset(hostId: number): void { this.failures.delete(hostId); } cleanup(): void { const maxAge = 60 * 60 * 1000; const now = Date.now(); for (const [hostId, record] of this.failures.entries()) { if (!record.permanent && now - record.lastFailure > maxAge) { this.failures.delete(hostId); } } } } class PollingBackoff { private failures = new Map(); private baseDelay = 30000; private maxDelay = 600000; private maxRetries = 5; recordFailure(hostId: number): void { const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 }; const delay = Math.min( this.baseDelay * Math.pow(2, existing.count), this.maxDelay, ); this.failures.set(hostId, { count: existing.count + 1, nextRetry: Date.now() + delay, }); } shouldSkip(hostId: number): boolean { const backoff = this.failures.get(hostId); if (!backoff) return false; if (backoff.count >= this.maxRetries) { return true; } return Date.now() < backoff.nextRetry; } getBackoffInfo(hostId: number): string | null { const backoff = this.failures.get(hostId); if (!backoff) return null; if (backoff.count >= this.maxRetries) { return `Max retries exceeded (${backoff.count} failures) - polling suspended`; } const remainingMs = backoff.nextRetry - Date.now(); if (remainingMs > 0) { const remainingSec = Math.ceil(remainingMs / 1000); return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`; } return null; } reset(hostId: number): void { this.failures.delete(hostId); } cleanup(): void { const maxAge = 60 * 60 * 1000; const now = Date.now(); for (const [hostId, backoff] of this.failures.entries()) { if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) { this.failures.delete(hostId); } } } } const requestQueue = new RequestQueue(); const metricsCache = new MetricsCache(); const authFailureTracker = new AuthFailureTracker(); const pollingBackoff = new PollingBackoff(); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); type HostStatus = "online" | "offline"; interface SSHHostWithCredentials { id: number; name: string; ip: string; port: number; username: string; folder: string; tags: string[]; pin: boolean; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; credentialId?: number; enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; defaultPath: string; tunnelConnections: unknown[]; jumpHosts?: Array<{ hostId: number }>; statsConfig?: string | StatsConfig; createdAt: string; updatedAt: string; userId: string; useSocks5?: boolean; socks5Host?: string; socks5Port?: number; socks5Username?: string; socks5Password?: string; socks5ProxyChain?: ProxyNode[]; connectionType?: "ssh" | "rdp" | "vnc" | "telnet"; } type StatusEntry = { status: HostStatus; lastChecked: string; }; interface StatsConfig { enabledWidgets: string[]; statusCheckEnabled: boolean; statusCheckInterval: number; useGlobalStatusInterval?: boolean; metricsEnabled: boolean; metricsInterval: number; useGlobalMetricsInterval?: boolean; disableTcpPing?: boolean; } const DEFAULT_STATS_CONFIG: StatsConfig = { enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], statusCheckEnabled: true, statusCheckInterval: 60, metricsEnabled: true, metricsInterval: 30, }; interface HostPollingConfig { host: SSHHostWithCredentials; statsConfig: StatsConfig; statusTimer?: NodeJS.Timeout; metricsTimer?: NodeJS.Timeout; viewerUserId?: string; } class PollingManager { private pollingConfigs = new Map(); private statusStore = new Map(); private metricsStore = new Map< number, { data: Awaited>; timestamp: number; } >(); private activeViewers = new Map>(); private viewerDetails = new Map(); private viewerCleanupInterval: NodeJS.Timeout; constructor() { this.viewerCleanupInterval = setInterval(() => { this.cleanupInactiveViewers(); }, 60000); } private getGlobalDefaults(): { statusCheckInterval: number; metricsInterval: number; } { try { const db = getDb(); const statusRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'global_status_check_interval'", ) .get() as { value: string } | undefined; const metricsRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'global_metrics_interval'", ) .get() as { value: string } | undefined; return { statusCheckInterval: statusRow ? parseInt(statusRow.value, 10) || DEFAULT_STATS_CONFIG.statusCheckInterval : DEFAULT_STATS_CONFIG.statusCheckInterval, metricsInterval: metricsRow ? parseInt(metricsRow.value, 10) || DEFAULT_STATS_CONFIG.metricsInterval : DEFAULT_STATS_CONFIG.metricsInterval, }; } catch { return { statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval, }; } } parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig { if (!statsConfigStr) { return DEFAULT_STATS_CONFIG; } let parsed: StatsConfig; if (typeof statsConfigStr === "object") { parsed = statsConfigStr; } else { try { let temp: unknown = JSON.parse(statsConfigStr); if (typeof temp === "string") { temp = JSON.parse(temp); } parsed = temp as StatsConfig; } catch (error) { statsLogger.warn( `Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`, { operation: "parse_stats_config_error", statsConfigStr, }, ); return DEFAULT_STATS_CONFIG; } } const result = { ...DEFAULT_STATS_CONFIG, ...parsed }; const globalDefaults = this.getGlobalDefaults(); if (result.useGlobalStatusInterval !== false) { result.statusCheckInterval = globalDefaults.statusCheckInterval; } if (result.useGlobalMetricsInterval !== false) { result.metricsInterval = globalDefaults.metricsInterval; } return result; } async startPollingForHost( host: SSHHostWithCredentials, options?: { statusOnly?: boolean; viewerUserId?: string }, ): Promise { const statsConfig = this.parseStatsConfig(host.statsConfig); const statusOnly = options?.statusOnly ?? false; const viewerUserId = options?.viewerUserId; const canCollectMetrics = supportsMetrics(host); const enabledCollectors: string[] = []; if (isTcpPingEnabled(statsConfig)) { enabledCollectors.push("status"); } if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) { enabledCollectors.push( "cpu", "memory", "disk", "network", "uptime", "processes", "system", ); } const existingConfig = this.pollingConfigs.get(host.id); if (existingConfig) { if (existingConfig.statusTimer) { clearInterval(existingConfig.statusTimer); existingConfig.statusTimer = undefined; } if (existingConfig.metricsTimer) { clearInterval(existingConfig.metricsTimer); existingConfig.metricsTimer = undefined; } } if (!isTcpPingEnabled(statsConfig) && !statsConfig.metricsEnabled) { this.pollingConfigs.delete(host.id); this.statusStore.delete(host.id); this.metricsStore.delete(host.id); return; } const config: HostPollingConfig = { host, statsConfig, viewerUserId, }; if (isTcpPingEnabled(statsConfig)) { const intervalMs = statsConfig.statusCheckInterval * 1000; this.pollHostStatus(host, viewerUserId); config.statusTimer = setInterval(() => { const latestConfig = this.pollingConfigs.get(host.id); if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) { this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId); } }, intervalMs); } else { this.statusStore.delete(host.id); } if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) { const intervalMs = statsConfig.metricsInterval * 1000; await this.pollHostMetrics(host, viewerUserId); config.metricsTimer = setInterval(() => { const latestConfig = this.pollingConfigs.get(host.id); if ( latestConfig && latestConfig.statsConfig.metricsEnabled && supportsMetrics(latestConfig.host) ) { this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId); } }, intervalMs); } else { this.metricsStore.delete(host.id); } this.pollingConfigs.set(host.id, config); } private async pollHostStatus( host: SSHHostWithCredentials, viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; const refreshedHost = await fetchHostById(host.id, userId); if (!refreshedHost) { return; } try { const isOnline = await tcpPing( refreshedHost.ip, refreshedHost.port, 5000, ); const statusEntry: StatusEntry = { status: isOnline ? "online" : "offline", lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); } catch { const statusEntry: StatusEntry = { status: "offline", lastChecked: new Date().toISOString(), }; this.statusStore.set(refreshedHost.id, statusEntry); } } private async pollHostMetrics( host: SSHHostWithCredentials, viewerUserId?: string, ): Promise { const userId = viewerUserId || host.userId; const refreshedHost = await fetchHostById(host.id, userId); if (!refreshedHost) { return; } if (!supportsMetrics(refreshedHost)) { statsLogger.debug("Skipping metrics collection for non-SSH host", { operation: "poll_host_metrics_skipped", hostId: refreshedHost.id, connectionType: refreshedHost.connectionType || "ssh", }); return; } const config = this.pollingConfigs.get(refreshedHost.id); if (!config || !config.statsConfig.metricsEnabled) { return; } const hasExistingMetrics = this.metricsStore.has(refreshedHost.id); if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) { return; } try { const metrics = await collectMetrics(refreshedHost); this.metricsStore.set(refreshedHost.id, { data: metrics, timestamp: Date.now(), }); pollingBackoff.reset(refreshedHost.id); } catch (error) { pollingBackoff.recordFailure(refreshedHost.id); const latestConfig = this.pollingConfigs.get(refreshedHost.id); if (latestConfig && latestConfig.statsConfig.metricsEnabled) { const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id); statsLogger.error("Stats collector connection failed", error, { operation: "stats_connect_failed", hostId: refreshedHost.id, retryInfo: backoffInfo, }); } } } stopPollingForHost(hostId: number, clearData = true): void { const config = this.pollingConfigs.get(hostId); if (config) { if (config.statusTimer) { clearInterval(config.statusTimer); config.statusTimer = undefined; } if (config.metricsTimer) { clearInterval(config.metricsTimer); config.metricsTimer = undefined; } this.pollingConfigs.delete(hostId); if (clearData) { this.statusStore.delete(hostId); this.metricsStore.delete(hostId); } } } stopMetricsOnly(hostId: number): void { const config = this.pollingConfigs.get(hostId); if (config?.metricsTimer) { clearInterval(config.metricsTimer); config.metricsTimer = undefined; } } getStatus(hostId: number): StatusEntry | undefined { return this.statusStore.get(hostId); } getAllStatuses(): Map { return this.statusStore; } getMetrics( hostId: number, ): | { data: Awaited>; timestamp: number } | undefined { return this.metricsStore.get(hostId); } async initializePolling(userId: string): Promise { const hosts = await fetchAllHosts(userId); for (const host of hosts) { await this.startPollingForHost(host, { statusOnly: true }); } } async refreshHostPolling(userId: string): Promise { const hosts = await fetchAllHosts(userId); const currentHostIds = new Set(hosts.map((h) => h.id)); for (const hostId of this.pollingConfigs.keys()) { this.stopPollingForHost(hostId, false); } for (const hostId of this.statusStore.keys()) { if (!currentHostIds.has(hostId)) { this.statusStore.delete(hostId); this.metricsStore.delete(hostId); } } for (const host of hosts) { await this.startPollingForHost(host, { statusOnly: true }); } } async refreshAllPolling(): Promise { const hostsToRefresh: Array<{ host: SSHHostWithCredentials; viewerUserId?: string; }> = []; for (const [hostId, config] of this.pollingConfigs.entries()) { const status = this.statusStore.get(hostId); if (!status || status.status === "online") { hostsToRefresh.push({ host: config.host, viewerUserId: config.viewerUserId, }); } } for (const hostId of this.pollingConfigs.keys()) { this.stopPollingForHost(hostId, false); } for (const { host, viewerUserId } of hostsToRefresh) { await this.startPollingForHost(host, { statusOnly: true, viewerUserId }); } const skipped = this.pollingConfigs.size - hostsToRefresh.length; } registerViewer(hostId: number, sessionId: string, userId: string): void { if (!this.activeViewers.has(hostId)) { this.activeViewers.set(hostId, new Set()); } this.activeViewers.get(hostId)!.add(sessionId); this.viewerDetails.set(sessionId, { sessionId, userId, hostId, lastHeartbeat: Date.now(), }); if (this.activeViewers.get(hostId)!.size === 1) { this.startMetricsForHost(hostId, userId); } } updateHeartbeat(sessionId: string): boolean { const viewer = this.viewerDetails.get(sessionId); if (viewer) { viewer.lastHeartbeat = Date.now(); return true; } return false; } unregisterViewer(hostId: number, sessionId: string): void { const viewers = this.activeViewers.get(hostId); if (viewers) { viewers.delete(sessionId); if (viewers.size === 0) { this.activeViewers.delete(hostId); this.stopMetricsForHost(hostId); } } this.viewerDetails.delete(sessionId); } private async startMetricsForHost( hostId: number, userId: string, ): Promise { try { const host = await fetchHostById(hostId, userId); if (host) { await this.startPollingForHost(host, { viewerUserId: userId }); } } catch (error) { statsLogger.error("Failed to start metrics polling", { operation: "start_metrics_error", hostId, error: error instanceof Error ? error.message : String(error), }); } } private stopMetricsForHost(hostId: number): void { this.stopMetricsOnly(hostId); } private cleanupInactiveViewers(): void { const now = Date.now(); const maxInactivity = 120000; for (const [sessionId, viewer] of this.viewerDetails.entries()) { if (now - viewer.lastHeartbeat > maxInactivity) { this.unregisterViewer(viewer.hostId, sessionId); } } } destroy(): void { clearInterval(this.viewerCleanupInterval); for (const hostId of this.pollingConfigs.keys()) { this.stopPollingForHost(hostId); } } } const pollingManager = new PollingManager(); function validateHostId( req: express.Request, res: express.Response, next: express.NextFunction, ) { const id = Number(req.params.id); if (!id || !Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: "Invalid host ID" }); } next(); } const app = express(); app.use( cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (allowedOrigins.includes(origin)) { return callback(null, true); } if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: [ "Content-Type", "Authorization", "User-Agent", "X-Electron-App", ], }), ); 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()); const requireAdmin = authManager.createAdminMiddleware(); async function fetchAllHosts( userId: string, ): Promise { try { const hostResults = await SimpleDBOps.select( getDb().select().from(hosts).where(eq(hosts.userId, userId)), "ssh_data", userId, ); const hostsWithCredentials: SSHHostWithCredentials[] = []; for (const host of hostResults) { try { const hostWithCreds = await resolveHostCredentials(host, userId); if (hostWithCreds) { hostsWithCredentials.push(hostWithCreds); } } catch (err) { statsLogger.warn( `Failed to resolve credentials for host ${host.id}: ${err instanceof Error ? err.message : "Unknown error"}`, ); } } return hostsWithCredentials.filter((h) => !!h.id && !!h.ip && !!h.port); } catch (err) { statsLogger.error("Failed to fetch hosts from database", err); return []; } } async function fetchHostById( id: number, userId: string, ): Promise { try { if (!SimpleDBOps.isUserDataUnlocked(userId)) { return undefined; } const accessInfo = await permissionManager.canAccessHost( userId, id, "read", ); if (!accessInfo.hasAccess) { statsLogger.warn(`User ${userId} cannot access host ${id}`, { operation: "fetch_host_access_denied", userId, hostId: id, }); return undefined; } const hostResults = await SimpleDBOps.select( getDb().select().from(hosts).where(eq(hosts.id, id)), "ssh_data", userId, ); if (hostResults.length === 0) { return undefined; } const host = hostResults[0]; return await resolveHostCredentials(host, userId); } catch (err) { statsLogger.error(`Failed to fetch host ${id}`, err); return undefined; } } async function resolveHostCredentials( host: Record, userId: string, ): Promise { try { const baseHost: Record = { id: host.id, name: host.name, ip: host.ip, port: host.port, username: host.username, folder: host.folder || "", tags: typeof host.tags === "string" ? host.tags ? host.tags.split(",").filter(Boolean) : [] : [], pin: !!host.pin, authType: host.authType, enableTerminal: !!host.enableTerminal, enableTunnel: !!host.enableTunnel, enableFileManager: !!host.enableFileManager, defaultPath: host.defaultPath || "/", tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], statsConfig: host.statsConfig || undefined, createdAt: host.createdAt, updatedAt: host.updatedAt, userId: host.userId, useSocks5: !!host.useSocks5, socks5Host: host.socks5Host || undefined, socks5Port: host.socks5Port || undefined, socks5Username: host.socks5Username || undefined, socks5Password: host.socks5Password || undefined, socks5ProxyChain: host.socks5ProxyChain ? JSON.parse(host.socks5ProxyChain as string) : undefined, }; if (host.credentialId) { try { const ownerId = host.userId; const isSharedHost = userId !== ownerId; if (isSharedHost) { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id as number, userId, ); if (sharedCred) { baseHost.credentialId = host.credentialId; baseHost.authType = sharedCred.authType; if (!host.overrideCredentialUsername) { baseHost.username = sharedCred.username; } if (sharedCred.password) { baseHost.password = sharedCred.password; } if (sharedCred.key) { baseHost.key = sharedCred.key; } if (sharedCred.keyPassword) { baseHost.keyPassword = sharedCred.keyPassword; } if (sharedCred.keyType) { baseHost.keyType = sharedCred.keyType; } } } else { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.id, host.credentialId as number)), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; baseHost.credentialId = credential.id; baseHost.authType = credential.authType || (credential.password ? "password" : credential.key || (credential as Record).privateKey ? "key" : "none"); if (!host.overrideCredentialUsername) { baseHost.username = credential.username; } if (credential.password) { baseHost.password = credential.password; } if (credential.key) { baseHost.key = credential.key; } if (credential.keyPassword) { baseHost.keyPassword = credential.keyPassword; } if (credential.keyType) { baseHost.keyType = credential.keyType; } } else { addLegacyCredentials(baseHost, host); if (baseHost.authType === "credential") { baseHost.authType = baseHost.password ? "password" : baseHost.key ? "key" : "none"; } } } } catch (error) { statsLogger.warn( `Failed to resolve credential ${host.credentialId} for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, ); addLegacyCredentials(baseHost, host); if (baseHost.authType === "credential") { baseHost.authType = baseHost.password ? "password" : baseHost.key ? "key" : "none"; } } } else { addLegacyCredentials(baseHost, host); } return baseHost as unknown as SSHHostWithCredentials; } catch (error) { statsLogger.error( `Failed to resolve host credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`, ); return undefined; } } function addLegacyCredentials( baseHost: Record, host: Record, ): void { baseHost.password = host.password || null; baseHost.key = host.key || null; baseHost.keyPassword = host.keyPassword || null; baseHost.keyType = host.keyType; } async function buildSshConfig( host: SSHHostWithCredentials, ): Promise { const base: ConnectConfig = { host: host.ip?.replace(/^\[|\]$/g, "") || host.ip, port: host.port, username: host.username, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( host.id, host.ip, host.port, null, host.userId || "", false, ), env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", LC_MESSAGES: "en_US.UTF-8", LC_MONETARY: "en_US.UTF-8", LC_NUMERIC: "en_US.UTF-8", LC_TIME: "en_US.UTF-8", LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, algorithms: { kex: [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", ], serverHostKey: [ "ssh-ed25519", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ssh-dss", ], cipher: [ "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes128-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr", "aes256-cbc", "aes192-cbc", "aes128-cbc", "3des-cbc", ], hmac: [ "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], compress: ["none", "zlib@openssh.com", "zlib"], }, } as ConnectConfig; if (host.authType === "password") { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); } base.password = host.password; } else if (host.authType === "key") { if (!host.key) { throw new Error(`No SSH key available for host ${host.ip}`); } 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", ); if (host.keyPassword) { (base as Record).passphrase = host.keyPassword; } } catch (keyError) { statsLogger.error( `SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, ); throw new Error(`Invalid SSH key format for host ${host.ip}`); } } else if (host.authType === "none") { // no credentials needed } else if (host.authType === "opkssh") { // handled externally } else if (host.authType === "credential") { if (host.password) { base.password = host.password; } else if (host.key) { const cleanKey = host.key .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); (base as Record).privateKey = Buffer.from( cleanKey, "utf8", ); if (host.keyPassword) { (base as Record).passphrase = host.keyPassword; } } else { throw new Error(`Credential for host ${host.ip} could not be resolved`); } } else { throw new Error( `Unsupported authentication type '${host.authType}' for host ${host.ip}`, ); } return base; } function getPoolKey(host: SSHHostWithCredentials): string { const socks5Key = host.useSocks5 ? `:socks5:${host.socks5Host}:${host.socks5Port}` : ""; return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`; } function createSshFactory(host: SSHHostWithCredentials): () => Promise { return async () => { const config = await buildSshConfig(host); const client = new Client(); const proxyConfig: SOCKS5Config | null = host.useSocks5 && (host.socks5Host || (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) ? { useSocks5: host.useSocks5, socks5Host: host.socks5Host, socks5Port: host.socks5Port, socks5Username: host.socks5Username, socks5Password: host.socks5Password, socks5ProxyChain: host.socks5ProxyChain, } : null; const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0 && host.userId; let jumpClient: Client | null = null; if (hasJumpHosts) { jumpClient = await createJumpHostChain( host.jumpHosts!, host.userId!, proxyConfig, ); if (!jumpClient) { throw new Error("Failed to establish jump host chain"); } } else if (proxyConfig) { try { const proxySocket = await createSocks5Connection( host.ip, host.port, proxyConfig, ); if (proxySocket) { config.sock = proxySocket; } } catch (proxyError) { throw new Error( "Proxy connection failed: " + (proxyError instanceof Error ? proxyError.message : "Unknown error"), ); } } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { client.end(); reject(new Error("SSH connection timeout")); }, 30000); client.on("ready", () => { clearTimeout(timeout); resolve(client); }); client.on("error", (err) => { clearTimeout(timeout); reject(err); }); client.on( "keyboard-interactive", ( _name: string, _instructions: string, _instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); if (totpPromptIndex !== -1) { const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId: host.id, userId: host.userId!, prompts: prompts.map((p) => ({ prompt: p.prompt, echo: p.echo ?? false, })), totpPromptIndex, resolvedPassword: host.password, totpAttempts: 0, }; return; } else if (host.password) { const responses = prompts.map((p) => { if (/password/i.test(p.prompt)) { return host.password || ""; } return ""; }); finish(responses); } else { finish(prompts.map(() => "")); } }, ); if (jumpClient) { jumpClient.forwardOut( "127.0.0.1", 0, host.ip, host.port, (err, stream) => { if (err) { clearTimeout(timeout); jumpClient!.end(); reject( new Error( "Failed to forward through jump host: " + err.message, ), ); return; } config.sock = stream; client.connect(config); }, ); } else { client.connect(config); } }); }; } async function withSshConnection( host: SSHHostWithCredentials, fn: (client: Client) => Promise, ): Promise { const key = getPoolKey(host); const factory = createSshFactory(host); return withConnection(key, factory, fn); } async function collectMetrics(host: SSHHostWithCredentials): Promise<{ cpu: { percent: number | null; cores: number | null; load: [number, number, number] | null; }; memory: { percent: number | null; usedGiB: number | null; totalGiB: number | null; }; disk: { percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; }; network: { interfaces: Array<{ name: string; ip: string; state: string; rxBytes: string | null; txBytes: string | null; }>; }; uptime: { seconds: number | null; formatted: string | null; }; processes: { total: number | null; running: number | null; top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string; }>; }; system: { hostname: string | null; kernel: string | null; os: string | null; }; }> { if (!supportsMetrics(host)) { throw new Error("Metrics collection only supported for SSH hosts"); } if (authFailureTracker.shouldSkip(host.id)) { const reason = authFailureTracker.getSkipReason(host.id); throw new Error(reason || "Authentication failed"); } const cached = metricsCache.get(host.id); if (cached) { return cached as ReturnType extends Promise ? T : never; } return requestQueue.queueRequest(host.id, async () => { const sessionKey = getSessionKey(host.id, host.userId!); const existingSession = metricsSessions[sessionKey]; try { const collectFn = async (client: Client) => { const cpu = await collectCpuMetrics(client); const memory = await collectMemoryMetrics(client); const disk = await collectDiskMetrics(client); const network = await collectNetworkMetrics(client); const uptime = await collectUptimeMetrics(client); const processes = await collectProcessesMetrics(client); const system = await collectSystemMetrics(client); let login_stats = { recentLogins: [], failedLogins: [], totalLogins: 0, uniqueIPs: 0, }; try { login_stats = await collectLoginStats(client); } catch { // expected } let ports: { source: "ss" | "netstat" | "none"; ports: Array<{ protocol: "tcp" | "udp"; localAddress: string; localPort: number; state?: string; pid?: number; process?: string; }>; } = { source: "none", ports: [], }; try { ports = await collectPortsMetrics(client); } catch { // expected } let firewall: { type: "iptables" | "nftables" | "none"; status: "active" | "inactive" | "unknown"; chains: Array<{ name: string; policy: string; rules: Array<{ chain: string; target: string; protocol: string; source: string; destination: string; dport?: string; sport?: string; state?: string; interface?: string; extra?: string; }>; }>; } = { type: "none", status: "unknown", chains: [], }; try { firewall = await collectFirewallMetrics(client); } catch { // expected } const result = { cpu, memory, disk, network, uptime, processes, system, login_stats, ports, firewall, }; metricsCache.set(host.id, result); return result; }; if (existingSession && existingSession.isConnected) { existingSession.activeOperations++; try { const result = await collectFn(existingSession.client); existingSession.lastActive = Date.now(); return result; } finally { existingSession.activeOperations--; } } else { return await withSshConnection(host, collectFn); } } catch (error) { if (error instanceof Error) { if (error.message.includes("TOTP authentication required")) { throw error; } else if ( error.message.includes("No password available") || error.message.includes("Unsupported authentication type") || error.message.includes("No SSH key available") ) { authFailureTracker.recordFailure(host.id, "AUTH", true); } else if ( error.message.includes("authentication") || error.message.includes("Permission denied") || error.message.includes("All configured authentication methods failed") ) { authFailureTracker.recordFailure(host.id, "AUTH"); } else if ( error.message.includes("timeout") || error.message.includes("ETIMEDOUT") ) { authFailureTracker.recordFailure(host.id, "TIMEOUT"); } } throw error; } }); } function tcpPing( host: string, port: number, timeoutMs = 5000, ): Promise { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; const finish = (result: boolean) => { if (settled) return; settled = true; resolve(result); }; const cleanup = () => { try { socket.destroy(); } catch { // expected } }; socket.setTimeout(timeoutMs); socket.once("connect", () => { const dataTimeout = setTimeout(() => { cleanup(); finish(true); }, 2000); socket.once("data", (data) => { clearTimeout(dataTimeout); const dataStr = data.toString("utf8"); if (dataStr.startsWith("SSH-")) { try { socket.end("SSH-2.0-TermixHealthCheck\r\n"); } catch { // expected } setTimeout(cleanup, 200); } else { cleanup(); } finish(true); }); }); socket.once("timeout", () => { cleanup(); finish(false); }); socket.once("error", () => { cleanup(); finish(false); }); socket.connect(port, host); }); } /** * @openapi * /status: * get: * summary: Get all host statuses * description: Retrieves the status of all hosts for the authenticated user. * tags: * - Server Stats * responses: * 200: * description: A map of host IDs to their status entries. * 401: * description: Session expired - please log in again. */ app.get("/status", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const statuses = pollingManager.getAllStatuses(); if (statuses.size === 0) { await pollingManager.initializePolling(userId); } const result: Record = {}; for (const [id, entry] of pollingManager.getAllStatuses().entries()) { result[id] = entry; } res.json(result); }); /** * @openapi * /status/{id}: * get: * summary: Get host status by ID * description: Retrieves the status of a specific host by its ID. * tags: * - Server Stats * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Host status entry. * 401: * description: Session expired - please log in again. * 404: * description: Status not available. */ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const statuses = pollingManager.getAllStatuses(); if (statuses.size === 0) { await pollingManager.initializePolling(userId); } const statusEntry = pollingManager.getStatus(id); if (!statusEntry) { return res.status(404).json({ error: "Status not available" }); } res.json(statusEntry); }); /** * @openapi * /clear-connections: * post: * summary: Clear all SSH connections * description: Clears all SSH connections from the connection pool. * tags: * - Server Stats * responses: * 200: * description: All SSH connections cleared. * 401: * description: Session expired - please log in again. */ app.post("/clear-connections", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } connectionPool.clearAllConnections(); res.json({ message: "All SSH connections cleared" }); }); /** * @openapi * /refresh: * post: * summary: Refresh polling * description: Clears all SSH connections and refreshes host polling. * tags: * - Server Stats * responses: * 200: * description: Polling refreshed. * 401: * description: Session expired - please log in again. */ app.post("/refresh", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } connectionPool.clearAllConnections(); await pollingManager.refreshHostPolling(userId); res.json({ message: "Polling refreshed" }); }); /** * @openapi * /host-updated: * post: * summary: Start polling for updated host * description: Starts polling for a specific host after it has been updated. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * responses: * 200: * description: Host polling started. * 400: * description: Invalid hostId. * 401: * description: Session expired - please log in again. * 404: * description: Host not found. * 500: * description: Failed to start polling. */ app.post("/host-updated", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!hostId || typeof hostId !== "number") { return res.status(400).json({ error: "Invalid hostId" }); } try { const host = await fetchHostById(hostId, userId); if (host) { connectionPool.clearKeyConnections(getPoolKey(host)); await pollingManager.startPollingForHost(host); res.json({ message: "Host polling started" }); } else { res.status(404).json({ error: "Host not found" }); } } catch (error) { statsLogger.error("Failed to start polling for host", error, { operation: "host_updated", hostId, userId, }); res.status(500).json({ error: "Failed to start polling" }); } }); /** * @openapi * /host-deleted: * post: * summary: Stop polling for deleted host * description: Stops polling for a specific host after it has been deleted. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * responses: * 200: * description: Host polling stopped. * 400: * description: Invalid hostId. * 401: * description: Session expired - please log in again. * 500: * description: Failed to stop polling. */ app.post("/host-deleted", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!hostId || typeof hostId !== "number") { return res.status(400).json({ error: "Invalid hostId" }); } try { pollingManager.stopPollingForHost(hostId, true); res.json({ message: "Host polling stopped" }); } catch (error) { statsLogger.error("Failed to stop polling for host", error, { operation: "host_deleted", hostId, userId, }); res.status(500).json({ error: "Failed to stop polling" }); } }); /** * @openapi * /metrics/{id}: * get: * summary: Get host metrics * description: Retrieves current metrics for a specific host including CPU, memory, disk, network, processes, and system information. * tags: * - Server Stats * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Host metrics data. * 401: * description: Session expired - please log in again. * 404: * description: Metrics not available. */ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } const metricsData = pollingManager.getMetrics(id); if (!metricsData) { return res.status(404).json({ error: "Metrics not available", cpu: { percent: null, cores: null, load: null }, memory: { percent: null, usedGiB: null, totalGiB: null }, disk: { percent: null, usedHuman: null, totalHuman: null, availableHuman: null, }, network: { interfaces: [] }, uptime: { seconds: null, formatted: null }, processes: { total: null, running: null, top: [] }, system: { hostname: null, kernel: null, os: null }, lastChecked: new Date().toISOString(), }); } res.json({ ...metricsData.data, lastChecked: new Date(metricsData.timestamp).toISOString(), }); }); /** * @openapi * /metrics/start/{id}: * post: * summary: Start metrics collection * description: Establishes an SSH connection and starts collecting metrics for a specific host. * tags: * - Server Stats * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: Metrics collection started successfully, or TOTP required. * 401: * description: Session expired - please log in again. * 404: * description: Host not found. * 500: * description: Failed to start metrics collection. */ app.post("/metrics/start/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; const connectionLogs: Array> = []; if (!SimpleDBOps.isUserDataUnlocked(userId)) { connectionLogs.push( createConnectionLog("error", "stats_connecting", "Session expired"), ); return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", connectionLogs, }); } try { const host = await fetchHostById(id, userId); if (!host) { connectionLogs.push( createConnectionLog("error", "stats_connecting", "Host not found"), ); return res.status(404).json({ error: "Host not found", connectionLogs }); } connectionLogs.push( createConnectionLog( "info", "stats_connecting", "Starting metrics collection", ), ); connectionLogs.push( createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`), ); connectionLogs.push( createConnectionLog( "info", "tcp", `Connecting to ${host.ip}:${host.port}`, ), ); connectionLogs.push( createConnectionLog("info", "handshake", "Initiating SSH handshake"), ); if (host.authType === "password") { connectionLogs.push( createConnectionLog("info", "auth", "Authenticating with password"), ); } else if (host.authType === "key") { connectionLogs.push( createConnectionLog("info", "auth", "Authenticating with SSH key"), ); } const sessionKey = getSessionKey(host.id, userId); const existingSession = metricsSessions[sessionKey]; if (existingSession && existingSession.isConnected) { connectionLogs.push( createConnectionLog( "success", "stats_polling", "Using existing metrics session", ), ); return res.json({ success: true, connectionLogs }); } const config = await buildSshConfig(host); const client = new Client(); const connectionPromise = new Promise<{ success: boolean; requires_totp?: boolean; sessionId?: string; prompt?: string; viewerSessionId?: string; }>((resolve, reject) => { let isResolved = false; const timeout = setTimeout(() => { if (!isResolved) { isResolved = true; client.end(); reject(new Error("Connection timeout")); } }, 60000); client.on( "keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => { const totpPromptIndex = prompts.findIndex((p) => /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test( p.prompt, ), ); if (totpPromptIndex !== -1) { const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; pendingTOTPSessions[sessionId] = { client, finish, config, createdAt: Date.now(), sessionId, hostId: host.id, userId: host.userId!, prompts: prompts.map((p) => ({ prompt: p.prompt, echo: p.echo ?? false, })), totpPromptIndex, resolvedPassword: host.password, totpAttempts: 0, }; connectionLogs.push( createConnectionLog( "info", "stats_totp", "TOTP verification required", ), ); clearTimeout(timeout); if (!isResolved) { isResolved = true; resolve({ success: false, requires_totp: true, sessionId, prompt: prompts[totpPromptIndex].prompt, }); } return; } else { const responses = prompts.map((p) => { if (/password/i.test(p.prompt) && host.password) { return host.password; } return ""; }); finish(responses); } }, ); client.on("ready", () => { clearTimeout(timeout); if (!isResolved) { isResolved = true; connectionLogs.push( createConnectionLog( "success", "connected", "SSH connection established successfully", ), ); connectionLogs.push( createConnectionLog( "success", "stats_polling", "Metrics session established", ), ); metricsSessions[sessionKey] = { client, isConnected: true, lastActive: Date.now(), activeOperations: 0, hostId: host.id, userId, }; scheduleMetricsSessionCleanup(sessionKey); const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; pollingManager.registerViewer(host.id, viewerSessionId, userId); resolve({ success: true, viewerSessionId }); } }); client.on("error", (error) => { clearTimeout(timeout); if (!isResolved) { isResolved = true; const errorMessage = error instanceof Error ? error.message : String(error); let errorStage: ConnectionStage = "error"; if ( errorMessage.includes("ENOTFOUND") || errorMessage.includes("getaddrinfo") ) { errorStage = "dns"; connectionLogs.push( createConnectionLog( "error", errorStage, `DNS resolution failed: ${errorMessage}`, ), ); } else if ( errorMessage.includes("ECONNREFUSED") || errorMessage.includes("ETIMEDOUT") ) { errorStage = "tcp"; connectionLogs.push( createConnectionLog( "error", errorStage, `TCP connection failed: ${errorMessage}`, ), ); } else if ( errorMessage.includes("handshake") || errorMessage.includes("key exchange") ) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH handshake failed: ${errorMessage}`, ), ); } else if ( errorMessage.includes("authentication") || errorMessage.includes("Authentication") ) { errorStage = "auth"; connectionLogs.push( createConnectionLog( "error", errorStage, `Authentication failed: ${errorMessage}`, ), ); } else if (errorMessage.includes("verification failed")) { errorStage = "handshake"; connectionLogs.push( createConnectionLog( "error", errorStage, `SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`, ), ); } else { connectionLogs.push( createConnectionLog( "error", "error", `SSH connection failed: ${errorMessage}`, ), ); } statsLogger.error("SSH connection error in metrics/start", { operation: "metrics_start_ssh_error", hostId: host.id, error: errorMessage, }); reject(error); } }); if ( host.useSocks5 && (host.socks5Host || (host.socks5ProxyChain && host.socks5ProxyChain.length > 0)) ) { connectionLogs.push( createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"), ); createSocks5Connection(host.ip, host.port, { useSocks5: host.useSocks5, socks5Host: host.socks5Host, socks5Port: host.socks5Port, socks5Username: host.socks5Username, socks5Password: host.socks5Password, socks5ProxyChain: host.socks5ProxyChain, }) .then((socks5Socket) => { if (socks5Socket) { config.sock = socks5Socket; } client.connect(config); }) .catch((error) => { if (!isResolved) { isResolved = true; clearTimeout(timeout); connectionLogs.push( createConnectionLog( "error", "proxy", `SOCKS5 proxy connection failed: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); reject(error); } }); } else { client.connect(config); } }); const result = await connectionPromise; res.json({ ...result, connectionLogs }); } catch (error) { statsLogger.error("Failed to start metrics collection", { operation: "metrics_start_error", hostId: id, error: error instanceof Error ? error.message : String(error), }); connectionLogs.push( createConnectionLog( "error", "stats_connecting", `Failed to start metrics: ${error instanceof Error ? error.message : "Unknown error"}`, ), ); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to start metrics collection", connectionLogs, }); } }); /** * @openapi * /metrics/stop/{id}: * post: * summary: Stop metrics collection * description: Stops metrics collection for a specific host and cleans up the SSH session. * tags: * - Server Stats * parameters: * - in: path * name: id * required: true * schema: * type: integer * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * viewerSessionId: * type: string * responses: * 200: * description: Metrics collection stopped successfully. * 401: * description: Session expired - please log in again. * 500: * description: Failed to stop metrics collection. */ app.post("/metrics/stop/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; const { viewerSessionId } = req.body; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } try { const sessionKey = getSessionKey(id, userId); const session = metricsSessions[sessionKey]; if (session) { cleanupMetricsSession(sessionKey); } if (viewerSessionId && typeof viewerSessionId === "string") { pollingManager.unregisterViewer(id, viewerSessionId); } else { pollingManager.stopMetricsOnly(id); } res.json({ success: true }); } catch (error) { statsLogger.error("Failed to stop metrics collection", { operation: "metrics_stop_error", hostId: id, error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: error instanceof Error ? error.message : "Failed to stop metrics collection", }); } }); /** * @openapi * /metrics/connect-totp: * post: * summary: Complete TOTP verification for metrics * description: Verifies the TOTP code and completes the metrics SSH connection. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * totpCode: * type: string * responses: * 200: * description: TOTP verified, metrics connection established. * 400: * description: Missing sessionId or totpCode. * 401: * description: Session expired or invalid TOTP code. * 404: * description: TOTP session not found or expired. * 500: * description: Failed to verify TOTP. */ app.post("/metrics/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!sessionId || !totpCode) { return res.status(400).json({ error: "Missing sessionId or totpCode" }); } const session = pendingTOTPSessions[sessionId]; if (!session) { return res.status(404).json({ error: "TOTP session not found or expired" }); } if (Date.now() - session.createdAt > 180000) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } return res.status(408).json({ error: "TOTP session timeout" }); } if (session.userId !== userId) { return res.status(403).json({ error: "Unauthorized" }); } session.totpAttempts++; if (session.totpAttempts > 3) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } return res.status(429).json({ error: "Too many TOTP attempts" }); } try { const responses = (session.prompts || []).map((p, idx) => { if (idx === session.totpPromptIndex) { return totpCode.trim(); } else if (/password/i.test(p.prompt) && session.resolvedPassword) { return session.resolvedPassword; } return ""; }); const connectionPromise = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("TOTP verification timeout")); }, 30000); session.client.once( "keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => { statsLogger.warn("Second keyboard-interactive received after TOTP", { operation: "totp_second_keyboard_interactive", hostId: session.hostId, sessionId, prompts: prompts.map((p) => p.prompt), }); const secondResponses = prompts.map((p) => { if (/password/i.test(p.prompt) && session.resolvedPassword) { return session.resolvedPassword; } return ""; }); finish(secondResponses); }, ); session.client.once("ready", () => { clearTimeout(timeout); resolve(); }); session.client.once("error", (error) => { clearTimeout(timeout); statsLogger.error("SSH client error after TOTP", { operation: "totp_client_error", hostId: session.hostId, sessionId, error: error instanceof Error ? error.message : String(error), }); reject(error); }); }); session.finish(responses); await connectionPromise; const sessionKey = getSessionKey(session.hostId, userId); metricsSessions[sessionKey] = { client: session.client, isConnected: true, lastActive: Date.now(), activeOperations: 0, hostId: session.hostId, userId, }; scheduleMetricsSessionCleanup(sessionKey); delete pendingTOTPSessions[sessionId]; const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; pollingManager.registerViewer(session.hostId, viewerSessionId, userId); res.json({ success: true, viewerSessionId }); } catch (error) { statsLogger.error("TOTP verification failed", { operation: "totp_verification_failed", hostId: session.hostId, sessionId, error: error instanceof Error ? error.message : String(error), }); if (session.totpAttempts >= 3) { delete pendingTOTPSessions[sessionId]; try { session.client.end(); } catch { // expected } } res.status(401).json({ error: "TOTP verification failed", attemptsRemaining: Math.max(0, 3 - session.totpAttempts), }); } }); /** * @openapi * /metrics/heartbeat: * post: * summary: Update viewer heartbeat * description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * viewerSessionId: * type: string * responses: * 200: * description: Heartbeat updated successfully. * 400: * description: Invalid viewerSessionId. * 401: * description: Session expired - please log in again. * 404: * description: Viewer session not found. * 500: * description: Failed to update heartbeat. */ app.post("/metrics/heartbeat", async (req, res) => { const { viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!viewerSessionId || typeof viewerSessionId !== "string") { return res.status(400).json({ error: "Invalid viewerSessionId" }); } try { const success = pollingManager.updateHeartbeat(viewerSessionId); if (success) { res.json({ success: true }); } else { res.status(404).json({ error: "Viewer session not found" }); } } catch (error) { statsLogger.error("Failed to update heartbeat", { operation: "heartbeat_error", viewerSessionId, error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: "Failed to update heartbeat" }); } }); /** * @openapi * /metrics/register-viewer: * post: * summary: Register metrics viewer * description: Registers a new viewer session for a host to track who is viewing metrics. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * responses: * 200: * description: Viewer registered successfully. * 400: * description: Invalid hostId. * 401: * description: Session expired - please log in again. * 500: * description: Failed to register viewer. */ app.post("/metrics/register-viewer", async (req, res) => { const { hostId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!hostId || typeof hostId !== "number") { return res.status(400).json({ error: "Invalid hostId" }); } try { const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; pollingManager.registerViewer(hostId, viewerSessionId, userId); res.json({ success: true, viewerSessionId }); } catch (error) { statsLogger.error("Failed to register viewer", { operation: "register_viewer_error", hostId, userId, error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: "Failed to register viewer" }); } }); /** * @openapi * /metrics/unregister-viewer: * post: * summary: Unregister metrics viewer * description: Unregisters a viewer session when they stop viewing metrics for a host. * tags: * - Server Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hostId: * type: integer * viewerSessionId: * type: string * responses: * 200: * description: Viewer unregistered successfully. * 400: * description: Invalid hostId or viewerSessionId. * 401: * description: Session expired - please log in again. * 500: * description: Failed to unregister viewer. */ app.post("/metrics/unregister-viewer", async (req, res) => { const { hostId, viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; if (!SimpleDBOps.isUserDataUnlocked(userId)) { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } if (!hostId || typeof hostId !== "number") { return res.status(400).json({ error: "Invalid hostId" }); } if (!viewerSessionId || typeof viewerSessionId !== "string") { return res.status(400).json({ error: "Invalid viewerSessionId" }); } try { pollingManager.unregisterViewer(hostId, viewerSessionId); res.json({ success: true }); } catch (error) { statsLogger.error("Failed to unregister viewer", { operation: "unregister_viewer_error", hostId, viewerSessionId, error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: "Failed to unregister viewer" }); } }); /** * @openapi * /global-settings: * get: * summary: Get global monitoring defaults * tags: * - Stats * responses: * 200: * description: Global monitoring settings. * 403: * description: Requires admin privileges. */ app.get("/global-settings", requireAdmin, async (_req, res) => { try { const db = getDb(); try { db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); } catch (tableError) { statsLogger.warn("Settings table does not exist, using defaults", { operation: "global_settings_table_check", error: tableError instanceof Error ? tableError.message : String(tableError), }); return res.json({ statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval, metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval, }); } const statusRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'global_status_check_interval'", ) .get() as { value: string } | undefined; const metricsRow = db.$client .prepare( "SELECT value FROM settings WHERE key = 'global_metrics_interval'", ) .get() as { value: string } | undefined; res.json({ statusCheckInterval: statusRow ? parseInt(statusRow.value, 10) : DEFAULT_STATS_CONFIG.statusCheckInterval, metricsInterval: metricsRow ? parseInt(metricsRow.value, 10) : DEFAULT_STATS_CONFIG.metricsInterval, }); } catch (error) { statsLogger.error("Failed to fetch global settings", { operation: "global_settings_fetch_error", error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: "Failed to fetch global settings" }); } }); /** * @openapi * /global-settings: * post: * summary: Update global monitoring defaults * tags: * - Stats * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * statusCheckInterval: * type: integer * metricsInterval: * type: integer * responses: * 200: * description: Settings saved. * 400: * description: Invalid parameters. * 403: * description: Requires admin privileges. */ app.post("/global-settings", requireAdmin, async (req, res) => { const { statusCheckInterval, metricsInterval } = req.body; if ( statusCheckInterval !== undefined && (typeof statusCheckInterval !== "number" || statusCheckInterval < 5 || statusCheckInterval > 3600) ) { return res.status(400).json({ error: "statusCheckInterval must be between 5 and 3600 seconds", }); } if ( metricsInterval !== undefined && (typeof metricsInterval !== "number" || metricsInterval < 5 || metricsInterval > 3600) ) { return res .status(400) .json({ error: "metricsInterval must be between 5 and 3600 seconds" }); } try { const db = getDb(); try { db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get(); } catch (tableError) { statsLogger.error("Settings table does not exist, cannot save settings", { operation: "global_settings_table_check", error: tableError instanceof Error ? tableError.message : String(tableError), }); return res.status(500).json({ error: "Database settings table is missing. Please check database initialization.", }); } if (statusCheckInterval !== undefined) { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)", ) .run(String(statusCheckInterval)); } if (metricsInterval !== undefined) { db.$client .prepare( "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)", ) .run(String(metricsInterval)); } await pollingManager.refreshAllPolling(); res.json({ success: true, message: "Settings updated and polling refreshed", }); } catch (error) { statsLogger.error("Failed to save global settings", { operation: "global_settings_save_error", error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: "Failed to save global settings", details: error instanceof Error ? error.message : String(error), }); } }); process.on("SIGINT", () => { pollingManager.destroy(); connectionPool.destroy(); process.exit(0); }); process.on("SIGTERM", () => { pollingManager.destroy(); connectionPool.destroy(); process.exit(0); }); const PORT = 30005; app.listen(PORT, async () => { try { await authManager.initialize(); } catch (err) { statsLogger.error("Failed to initialize AuthManager", err, { operation: "auth_init_error", }); } setInterval( () => { authFailureTracker.cleanup(); pollingBackoff.cleanup(); }, 10 * 60 * 1000, ); }); ================================================ FILE: src/backend/ssh/ssh-connection-pool.ts ================================================ import { Client } from "ssh2"; import { sshLogger } from "../utils/logger.js"; interface PooledConnection { client: Client; lastUsed: number; inUse: boolean; hostKey: string; } class SSHConnectionPool { private connections = new Map(); private maxConnectionsPerHost = 3; private cleanupInterval: NodeJS.Timeout; constructor() { this.cleanupInterval = setInterval( () => { this.cleanup(); }, 2 * 60 * 1000, ); } private isConnectionHealthy(client: Client): boolean { try { const sock = ( client as unknown as { _sock?: { destroyed?: boolean; writable?: boolean }; } )._sock; if (sock && (sock.destroyed || !sock.writable)) { return false; } return true; } catch { return false; } } async getConnection( key: string, factory: () => Promise, ): Promise { let connections = this.connections.get(key) || []; const available = connections.find((conn) => !conn.inUse); if (available) { if (!this.isConnectionHealthy(available.client)) { sshLogger.warn("Removing unhealthy connection from pool", { operation: "pool_remove_dead", hostKey: key, }); try { available.client.end(); } catch { // expected } connections = connections.filter((c) => c !== available); this.connections.set(key, connections); } else { available.inUse = true; available.lastUsed = Date.now(); return available.client; } } if (connections.length < this.maxConnectionsPerHost) { const client = await factory(); const pooled: PooledConnection = { client, lastUsed: Date.now(), inUse: true, hostKey: key, }; connections.push(pooled); this.connections.set(key, connections); client.on("end", () => { this.removeConnection(key, client); }); client.on("close", () => { this.removeConnection(key, client); }); return client; } return new Promise((resolve) => { const checkAvailable = () => { const conns = this.connections.get(key) || []; const avail = conns.find((conn) => !conn.inUse); if (avail) { if (!this.isConnectionHealthy(avail.client)) { try { avail.client.end(); } catch { // expected } const filtered = conns.filter((c) => c !== avail); this.connections.set(key, filtered); factory().then((client) => { const pooled: PooledConnection = { client, lastUsed: Date.now(), inUse: true, hostKey: key, }; filtered.push(pooled); this.connections.set(key, filtered); client.on("end", () => this.removeConnection(key, client)); client.on("close", () => this.removeConnection(key, client)); resolve(client); }); } else { avail.inUse = true; avail.lastUsed = Date.now(); resolve(avail.client); } } else { setTimeout(checkAvailable, 100); } }; checkAvailable(); }); } releaseConnection(key: string, client: Client): void { const connections = this.connections.get(key) || []; const pooled = connections.find((conn) => conn.client === client); if (pooled) { pooled.inUse = false; pooled.lastUsed = Date.now(); } } private removeConnection(key: string, client: Client): void { const connections = this.connections.get(key); if (!connections) return; const filtered = connections.filter((c) => c.client !== client); if (filtered.length === 0) { this.connections.delete(key); } else { this.connections.set(key, filtered); } } clearKeyConnections(key: string): void { const connections = this.connections.get(key) || []; for (const conn of connections) { try { conn.client.end(); } catch { // expected } } this.connections.delete(key); } private cleanup(): void { const now = Date.now(); const maxAge = 10 * 60 * 1000; for (const [hostKey, connections] of this.connections.entries()) { const activeConnections = connections.filter((conn) => { if (!conn.inUse && now - conn.lastUsed > maxAge) { try { conn.client.end(); } catch { // expected } return false; } if (!this.isConnectionHealthy(conn.client)) { try { conn.client.end(); } catch { // expected } return false; } return true; }); if (activeConnections.length === 0) { this.connections.delete(hostKey); } else { this.connections.set(hostKey, activeConnections); } } } clearAllConnections(): void { for (const connections of this.connections.values()) { for (const conn of connections) { try { conn.client.end(); } catch { // expected } } } this.connections.clear(); } destroy(): void { clearInterval(this.cleanupInterval); this.clearAllConnections(); } } export const connectionPool = new SSHConnectionPool(); export async function withConnection( key: string, factory: () => Promise, fn: (client: Client) => Promise, ): Promise { const client = await connectionPool.getConnection(key, factory); try { return await fn(client); } finally { connectionPool.releaseConnection(key, client); } } ================================================ FILE: src/backend/ssh/terminal-session-manager.ts ================================================ import { type Client, type ClientChannel } from "ssh2"; import { WebSocket } from "ws"; import { sshLogger } from "../utils/logger.js"; import { getDb } from "../database/db/index.js"; const MAX_BUFFER_BYTES = 512 * 1024; const DEFAULT_TIMEOUT_MINUTES = 30; const HEALTH_CHECK_INTERVAL_MS = 60_000; const MAX_SESSIONS_PER_USER = 10; export interface TerminalSession { id: string; userId: string; hostId: number; hostName: string; tabInstanceId?: string; attachedTabInstanceId?: string; sshConn: Client | null; sshStream: ClientChannel | null; jumpClient: Client | null; opksshTempFiles: { keyPath: string; certPath: string } | null; cols: number; rows: number; isConnected: boolean; createdAt: number; attachedWs: WebSocket | null; lastDetachedAt: number | null; detachTimeout: NodeJS.Timeout | null; outputBuffer: string[]; outputBufferBytes: number; } class TerminalSessionManager { private static instance: TerminalSessionManager; private sessions = new Map(); private healthCheckTimer: NodeJS.Timeout | null = null; private constructor() { this.healthCheckTimer = setInterval( () => this.healthCheck(), HEALTH_CHECK_INTERVAL_MS, ); } static getInstance(): TerminalSessionManager { if (!TerminalSessionManager.instance) { TerminalSessionManager.instance = new TerminalSessionManager(); } return TerminalSessionManager.instance; } createSession( userId: string, hostId: number, hostName: string, cols: number, rows: number, tabInstanceId?: string, ): string { const userSessions = this.getUserSessions(userId); if (userSessions.length >= MAX_SESSIONS_PER_USER) { const detached = userSessions .filter((s) => s.attachedWs === null) .sort( (a, b) => (a.lastDetachedAt ?? a.createdAt) - (b.lastDetachedAt ?? b.createdAt), ); if (detached.length > 0) { this.destroySession(detached[0].id); } } if (tabInstanceId) { const tabSessions = userSessions.filter( (s) => s.tabInstanceId === tabInstanceId, ); if (tabSessions.length > 0) { sshLogger.warn("Tab instance already has session, destroying old", { operation: "session_tab_duplicate_cleanup", existingSessionId: tabSessions[0].id, tabInstanceId, }); this.destroySession(tabSessions[0].id); } } const id = crypto.randomUUID(); const session: TerminalSession = { id, userId, hostId, hostName, tabInstanceId, sshConn: null, sshStream: null, jumpClient: null, opksshTempFiles: null, cols, rows, isConnected: false, createdAt: Date.now(), attachedWs: null, lastDetachedAt: null, detachTimeout: null, outputBuffer: [], outputBufferBytes: 0, }; this.sessions.set(id, session); sshLogger.info("Terminal session created", { operation: "session_created", sessionId: id, userId, hostId, }); return id; } getSession(sessionId: string | null): TerminalSession | null { if (!sessionId) return null; return this.sessions.get(sessionId) ?? null; } setSSHState( sessionId: string, conn: Client, stream: ClientChannel, jumpClient?: Client | null, opksshTempFiles?: { keyPath: string; certPath: string } | null, ): void { const session = this.sessions.get(sessionId); if (!session) return; session.sshConn = conn; session.sshStream = stream; session.jumpClient = jumpClient ?? null; session.opksshTempFiles = opksshTempFiles ?? null; session.isConnected = true; } attachWs( sessionId: string, userId: string, ws: WebSocket, tabInstanceId?: string, ): TerminalSession | null { const session = this.sessions.get(sessionId); if (!session) { sshLogger.warn("Session not found for attachment", { operation: "session_attach_not_found", sessionId, userId, }); return null; } if (session.userId !== userId) { sshLogger.warn("Session userId mismatch", { operation: "session_attach_user_mismatch", sessionId, expectedUserId: session.userId, providedUserId: userId, }); return null; } if (!session.isConnected) { sshLogger.warn("Session not connected", { operation: "session_attach_not_connected", sessionId, userId, createdAt: session.createdAt, elapsed: Date.now() - session.createdAt, }); return null; } const isDetached = !session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN; const isOriginalTab = session.tabInstanceId === tabInstanceId; if ( !isDetached && !isOriginalTab && session.tabInstanceId && tabInstanceId ) { sshLogger.warn("Session actively attached to different tab instance", { operation: "session_attach_instance_conflict", sessionId, sessionInstanceId: session.tabInstanceId, providedInstanceId: tabInstanceId, }); try { ws.send( JSON.stringify({ type: "sessionExpired", sessionId, message: "Session belongs to a different tab instance", }), ); } catch { /* ignore */ } return null; } if ( session.tabInstanceId && tabInstanceId && session.tabInstanceId !== tabInstanceId ) { sshLogger.info( "Session attached to different tab instance (split-screen)", { operation: "session_attach_split_screen", originalInstanceId: session.tabInstanceId, newInstanceId: tabInstanceId, sessionId, }, ); } if (session.attachedWs && session.attachedWs !== ws) { try { session.attachedWs.send( JSON.stringify({ type: "sessionTakenOver", sessionId, message: "Session was attached from another tab", }), ); } catch { /* ignore */ } session.attachedWs = null; } if (session.detachTimeout) { clearTimeout(session.detachTimeout); session.detachTimeout = null; } session.attachedWs = ws; session.attachedTabInstanceId = tabInstanceId; session.lastDetachedAt = null; sshLogger.info("WebSocket attached to session", { operation: "session_attach", sessionId, userId, tabInstanceId, }); return session; } detachWs(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; if (session.detachTimeout) { clearTimeout(session.detachTimeout); session.detachTimeout = null; } session.attachedWs = null; session.lastDetachedAt = Date.now(); const timeoutMs = this.getTimeoutMs(); session.detachTimeout = setTimeout(() => { sshLogger.info("Session idle timeout expired", { operation: "session_idle_timeout", sessionId, userId: session.userId, }); this.destroySession(sessionId); }, timeoutMs); sshLogger.info("WebSocket detached from session", { operation: "session_detach", sessionId, userId: session.userId, timeoutMinutes: timeoutMs / 60_000, }); } destroySession(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; if (session.detachTimeout) { clearTimeout(session.detachTimeout); session.detachTimeout = null; } if (session.sshStream) { try { session.sshStream.end(); } catch { /* ignore */ } session.sshStream = null; } if (session.sshConn) { try { session.sshConn.end(); } catch { /* ignore */ } session.sshConn = null; } if (session.jumpClient) { try { session.jumpClient.end(); } catch { /* ignore */ } session.jumpClient = null; } if (session.opksshTempFiles) { const tempFiles = session.opksshTempFiles; session.opksshTempFiles = null; this.cleanupOpksshFiles(tempFiles); } session.isConnected = false; session.outputBuffer = []; session.outputBufferBytes = 0; this.sessions.delete(sessionId); sshLogger.info("Terminal session destroyed", { operation: "session_destroyed", sessionId, userId: session.userId, hostId: session.hostId, }); } getUserSessions(userId: string): TerminalSession[] { const result: TerminalSession[] = []; for (const session of this.sessions.values()) { if (session.userId === userId) { result.push(session); } } return result; } bufferOutput(sessionId: string, data: string): void { const session = this.sessions.get(sessionId); if (!session) return; session.outputBuffer.push(data); session.outputBufferBytes += data.length; while ( session.outputBufferBytes > MAX_BUFFER_BYTES && session.outputBuffer.length > 0 ) { const removed = session.outputBuffer.shift(); if (removed) session.outputBufferBytes -= removed.length; } } flushBuffer(session: TerminalSession): string | null { if (session.outputBuffer.length === 0) return null; const data = session.outputBuffer.join(""); session.outputBuffer = []; session.outputBufferBytes = 0; return data; } getBuffer(session: TerminalSession): string | null { if (session.outputBuffer.length === 0) return null; return session.outputBuffer.join(""); } private getTimeoutMs(): number { try { const db = getDb(); const row = db.$client .prepare( "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'", ) .get() as { value: string } | undefined; if (row) { const minutes = parseInt(row.value, 10); if (!isNaN(minutes) && minutes > 0) { return minutes * 60_000; } } } catch { // DB not available, use default } return DEFAULT_TIMEOUT_MINUTES * 60_000; } private healthCheck(): void { const toDestroy: string[] = []; const now = Date.now(); const GRACE_PERIOD_MS = 10_000; for (const [id, session] of this.sessions) { if (!session.isConnected) continue; if ( session.attachedWs && session.attachedWs.readyState === WebSocket.OPEN ) { continue; } if (session.sshStream?.destroyed) { const detachedDuration = session.lastDetachedAt ? now - session.lastDetachedAt : 0; if (detachedDuration > GRACE_PERIOD_MS) { sshLogger.info( "SSH stream destroyed during detach window, cleaning up", { operation: "session_health_check_stream_destroyed", sessionId: id, userId: session.userId, detachedFor: detachedDuration, }, ); toDestroy.push(id); } } if (!session.sshConn) { toDestroy.push(id); } } for (const id of toDestroy) { this.destroySession(id); } } private async cleanupOpksshFiles(tempFiles: { keyPath: string; certPath: string; }): Promise { try { const { promises: fs } = await import("fs"); const results = await Promise.allSettled([ fs.unlink(tempFiles.keyPath), fs.unlink(tempFiles.certPath), ]); results.forEach((result, index) => { if (result.status === "rejected") { sshLogger.warn("Failed to cleanup OPKSSH temp file", { operation: "opkssh_temp_cleanup_failed", file: index === 0 ? "keyPath" : "certPath", }); } }); } catch (error) { sshLogger.error("Failed to cleanup OPKSSH temp files", { operation: "opkssh_temp_cleanup_error", error, }); } } destroyAll(): void { for (const id of [...this.sessions.keys()]) { this.destroySession(id); } if (this.healthCheckTimer) { clearInterval(this.healthCheckTimer); this.healthCheckTimer = null; } } } export const sessionManager = TerminalSessionManager.getInstance(); ================================================ FILE: src/backend/ssh/terminal.ts ================================================ import { WebSocketServer, WebSocket, type RawData } from "ws"; import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2"; import { parse as parseUrl } from "url"; import axios from "axios"; import { getDb } from "../database/db/index.js"; import { sshCredentials, hosts } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { sshLogger, authLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import { UserCrypto } from "../utils/user-crypto.js"; import { createSocks5Connection, type SOCKS5Config, } from "../utils/socks5-helper.js"; import { SSHAuthManager } from "./auth-manager.js"; import type { ProxyNode } from "../../types/index.js"; import { SSHHostKeyVerifier } from "./host-key-verifier.js"; import { sessionManager } from "./terminal-session-manager.js"; interface ConnectToHostData { cols: number; rows: number; hostConfig: { id: number; instanceId?: string; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; jumpHosts?: Array<{ hostId: number }>; useSocks5?: boolean; socks5Host?: string; socks5Port?: number; socks5Username?: string; socks5Password?: string; socks5ProxyChain?: unknown; terminalConfig?: { keepaliveInterval?: number; keepaliveCountMax?: number; [key: string]: unknown; }; }; initialPath?: string; executeCommand?: string; } interface ResizeData { cols: number; rows: number; } interface TOTPResponseData { code?: string; } interface WebSocketMessage { type: string; data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown; code?: string; [key: string]: unknown; } const authManager = AuthManager.getInstance(); const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); interface JumpHostConfig { id: number; ip: string; port: number; username: string; password?: string; key?: string; keyPassword?: string; keyType?: string; authType?: string; credentialId?: number; [key: string]: unknown; } async function resolveJumpHost( hostId: number, userId: string, ): Promise { sshLogger.info("Resolving jump host", { operation: "terminal_jumphost_resolve", userId, hostId, }); try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))), "ssh_data", userId, ); if (hostResults.length === 0) { return null; } const host = hostResults[0]; if (host.credentialId) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, host.credentialId as number), eq(sshCredentials.userId, userId), ), ), "ssh_credentials", userId, ); if (credentials.length > 0) { const credential = credentials[0]; return { ...host, password: credential.password as string | undefined, key: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, } as JumpHostConfig; } } return host as JumpHostConfig; } catch (error) { sshLogger.error("Failed to resolve jump host", error, { operation: "resolve_jump_host", hostId, userId, }); return null; } } async function createJumpHostChain( jumpHosts: Array<{ hostId: number }>, userId: string, socks5Config?: SOCKS5Config | null, ): Promise { if (!jumpHosts || jumpHosts.length === 0) { return null; } let currentClient: Client | null = null; const clients: Client[] = []; try { const jumpHostConfigs = await Promise.all( jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)), ); const totalHops = jumpHostConfigs.length; for (let i = 0; i < jumpHostConfigs.length; i++) { if (!jumpHostConfigs[i]) { sshLogger.error(`Jump host ${i + 1} not found`, undefined, { operation: "jump_host_chain", hostId: jumpHosts[i].hostId, hopIndex: i, totalHops, }); clients.forEach((c) => c.end()); return null; } } let proxySocket: import("net").Socket | null = null; if (socks5Config?.useSocks5) { const firstHop = jumpHostConfigs[0]; proxySocket = await createSocks5Connection( firstHop.ip, firstHop.port || 22, socks5Config, ); } for (let i = 0; i < jumpHostConfigs.length; i++) { const jumpHostConfig = jumpHostConfigs[i]; const jumpClient = new Client(); clients.push(jumpClient); const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier( jumpHostConfig.id, jumpHostConfig.ip, jumpHostConfig.port || 22, null, userId, true, ); const connected = await new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 30000); jumpClient.on("ready", () => { clearTimeout(timeout); sshLogger.success("Jump host connection established", { operation: "terminal_jumphost_connected", userId, hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, depth: i, hopIndex: i, totalHops, usedProxySocket: i === 0 && !!proxySocket, }); resolve(true); }); jumpClient.on("error", (err) => { clearTimeout(timeout); sshLogger.error( `Jump host ${i + 1}/${totalHops} connection failed`, err, { operation: "jump_host_connect", hostId: jumpHostConfig.id, ip: jumpHostConfig.ip, hopIndex: i, totalHops, previousHop: i > 0 ? jumpHostConfigs[i - 1]?.ip : proxySocket ? "proxy" : "direct", usedProxySocket: i === 0 && !!proxySocket, }, ); resolve(false); }); const connectConfig: Record = { host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip, port: jumpHostConfig.port || 22, username: jumpHostConfig.username, tryKeyboard: true, readyTimeout: 30000, hostVerifier: jumpHostVerifier, }; if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { connectConfig.password = jumpHostConfig.password; } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { const cleanKey = jumpHostConfig.key .trim() .replace(/\r\n/g, "\n") .replace(/\r/g, "\n"); connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); if (jumpHostConfig.keyPassword) { connectConfig.passphrase = jumpHostConfig.keyPassword; } } if (currentClient) { currentClient.forwardOut( "127.0.0.1", 0, jumpHostConfig.ip, jumpHostConfig.port || 22, (err, stream) => { if (err) { clearTimeout(timeout); resolve(false); return; } connectConfig.sock = stream; jumpClient.connect(connectConfig); }, ); } else if (proxySocket) { connectConfig.sock = proxySocket; jumpClient.connect(connectConfig); } else { jumpClient.connect(connectConfig); } }); if (!connected) { clients.forEach((c) => c.end()); return null; } currentClient = jumpClient; } return currentClient; } catch (error) { sshLogger.error("Failed to create jump host chain", error, { operation: "jump_host_chain", }); clients.forEach((c) => c.end()); return null; } } const wss = new WebSocketServer({ port: 30002, verifyClient: async (info) => { try { const url = parseUrl(info.req.url!, true); const token = url.query.token as string; if (!token) { return false; } const payload = await authManager.verifyJWTToken(token); if (!payload) { return false; } if (payload.pendingTOTP) { return false; } const existingConnections = userConnections.get(payload.userId); if (existingConnections && existingConnections.size >= 3) { return false; } return true; } catch (error) { sshLogger.error("WebSocket authentication error", error, { operation: "websocket_auth_error", ip: info.req.socket.remoteAddress, }); return false; } }, }); wss.on("connection", async (ws: WebSocket, req) => { let userId: string | undefined; let sessionId: string | undefined; try { const url = parseUrl(req.url!, true); const token = url.query.token as string; if (!token) { ws.close(1008, "Authentication required"); return; } const payload = await authManager.verifyJWTToken(token); if (!payload) { ws.close(1008, "Authentication required"); return; } userId = payload.userId; sessionId = payload.sessionId; } catch (error) { sshLogger.error( "WebSocket JWT verification failed during connection", error, { operation: "websocket_connection_auth_error", ip: req.socket.remoteAddress, }, ); ws.close(1008, "Authentication required"); return; } const dataKey = userCrypto.getUserDataKey(userId); if (!dataKey) { ws.send( JSON.stringify({ type: "error", message: "Data locked - re-authenticate with password", code: "DATA_LOCKED", }), ); ws.close(1008, "Data access required"); return; } if (!userConnections.has(userId)) { userConnections.set(userId, new Set()); } const userWs = userConnections.get(userId)!; userWs.add(ws); sshLogger.info("Terminal WebSocket connection established", { operation: "terminal_ws_connect", sessionId, userId, }); let currentSessionId: string | null = null; let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; let lastJumpClient: Client | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; let totpTimeout: NodeJS.Timeout | null = null; let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; let isConnecting = false; let isConnected = false; let isCleaningUp = false; let isShellInitializing = false; let warpgateAuthPromptSent = false; let warpgateAuthTimeout: NodeJS.Timeout | null = null; let isAwaitingAuthCredentials = false; let opksshTempFiles: { keyPath: string; certPath: string } | null = null; const wsPingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.ping(); } }, 30000); ws.on("close", () => { clearInterval(wsPingInterval); sshLogger.info("Terminal WebSocket disconnected", { operation: "terminal_ws_disconnect", sessionId, userId, }); const userWs = userConnections.get(userId); if (userWs) { userWs.delete(ws); if (userWs.size === 0) { userConnections.delete(userId); } } if (currentSessionId) { const session = sessionManager.getSession(currentSessionId); if (session?.isConnected) { sessionManager.detachWs(currentSessionId); } else { sessionManager.destroySession(currentSessionId); currentSessionId = null; } } cleanupAuthState(); }); function resetConnectionState() { isConnecting = false; isConnected = false; isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; totpPromptSent = false; warpgateAuthPromptSent = false; } ws.on("message", async (msg: RawData) => { const currentDataKey = userCrypto.getUserDataKey(userId); if (!currentDataKey) { ws.send( JSON.stringify({ type: "error", message: "Data access expired - please re-authenticate", code: "DATA_EXPIRED", }), ); ws.close(1008, "Data access expired"); return; } let parsed: WebSocketMessage; try { parsed = JSON.parse(msg.toString()) as WebSocketMessage; } catch (e) { sshLogger.error("Invalid JSON received", e, { operation: "websocket_message_invalid_json", userId, messageLength: msg.toString().length, }); ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); return; } const { type, data } = parsed; switch (type) { case "connectToHost": { const connectData = data as ConnectToHostData; if (connectData.hostConfig) { connectData.hostConfig.userId = userId; } handleConnectToHost(connectData).catch((error) => { sshLogger.error("Failed to connect to host", error, { operation: "ssh_connect", userId, hostId: connectData.hostConfig?.id, ip: connectData.hostConfig?.ip, }); ws.send( JSON.stringify({ type: "error", message: "Failed to connect to host: " + (error instanceof Error ? error.message : "Unknown error"), }), ); }); break; } case "attachSession": { const attachData = data as { sessionId: string; cols: number; rows: number; tabInstanceId?: string; }; sshLogger.info("Attempting to attach session", { operation: "terminal_attach_session", sessionId: attachData.sessionId, tabInstanceId: attachData.tabInstanceId, userId, requestedCols: attachData.cols, requestedRows: attachData.rows, }); const session = sessionManager.attachWs( attachData.sessionId, userId, ws, attachData.tabInstanceId, ); if (session) { sshLogger.success("Session attached successfully", { operation: "terminal_attach_success", sessionId: attachData.sessionId, sessionCreatedAt: session.createdAt, wasDetached: !!session.lastDetachedAt, detachedDuration: session.lastDetachedAt ? Date.now() - session.lastDetachedAt : 0, }); currentSessionId = attachData.sessionId; sshStream = session.sshStream; sshConn = session.sshConn; isConnecting = false; isConnected = true; const buffered = sessionManager.getBuffer(session); if (buffered) { ws.send(JSON.stringify({ type: "data", data: buffered })); } if ( attachData.cols !== session.cols || attachData.rows !== session.rows ) { session.sshStream?.setWindow( attachData.rows, attachData.cols, attachData.rows, attachData.cols, ); session.cols = attachData.cols; session.rows = attachData.rows; } ws.send( JSON.stringify({ type: "sessionAttached", sessionId: attachData.sessionId, }), ); ws.send( JSON.stringify({ type: "connected", message: "Session reattached", }), ); } else { sshLogger.warn( "Session attachment failed - will create new connection", { operation: "terminal_attach_failed", sessionId: attachData.sessionId, tabInstanceId: attachData.tabInstanceId, userId, reason: "session_not_found_or_invalid", }, ); ws.send( JSON.stringify({ type: "sessionExpired", sessionId: attachData.sessionId, }), ); } break; } case "listSessions": { const sessions = sessionManager.getUserSessions(userId); ws.send( JSON.stringify({ type: "sessionList", sessions: sessions.map((s) => ({ id: s.id, hostId: s.hostId, hostName: s.hostName, createdAt: s.createdAt, lastDetachedAt: s.lastDetachedAt, })), }), ); break; } case "resize": { const resizeData = data as ResizeData; handleResize(resizeData); break; } case "disconnect": if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(); sshConn = null; sshStream = null; break; case "input": { const inputData = data as string; const inputStream = sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; if (inputStream) { if (inputData === "\t") { inputStream.write(inputData); } else if ( typeof inputData === "string" && inputData.startsWith("\x1b") ) { inputStream.write(inputData); } else { try { inputStream.write(Buffer.from(inputData, "utf8")); } catch (error) { sshLogger.error("Error writing input to SSH stream", error, { operation: "ssh_input_encoding", userId, dataLength: inputData.length, }); inputStream.write(Buffer.from(inputData, "latin1")); } } } break; } case "ping": ws.send(JSON.stringify({ type: "pong" })); break; case "totp_response": { const totpData = data as TOTPResponseData; if (keyboardInteractiveFinish && totpData?.code) { if (totpTimeout) { clearTimeout(totpTimeout); totpTimeout = null; } const totpCode = totpData.code; keyboardInteractiveFinish([totpCode]); keyboardInteractiveFinish = null; totpPromptSent = false; } else { sshLogger.warn("TOTP response received but no callback available", { operation: "totp_response_error", userId, hasCallback: !!keyboardInteractiveFinish, hasCode: !!totpData?.code, }); ws.send( JSON.stringify({ type: "error", message: "TOTP authentication state lost. Please reconnect.", }), ); } break; } case "password_response": { const passwordData = data as TOTPResponseData; if (keyboardInteractiveFinish && passwordData?.code) { if (totpTimeout) { clearTimeout(totpTimeout); totpTimeout = null; } const password = passwordData.code; keyboardInteractiveFinish([password]); keyboardInteractiveFinish = null; } else { sshLogger.warn( "Password response received but no callback available", { operation: "password_response_error", userId, hasCallback: !!keyboardInteractiveFinish, hasCode: !!passwordData?.code, }, ); ws.send( JSON.stringify({ type: "error", message: "Password authentication state lost. Please reconnect.", }), ); } break; } case "warpgate_auth_continue": { if (keyboardInteractiveFinish) { if (warpgateAuthTimeout) { clearTimeout(warpgateAuthTimeout); warpgateAuthTimeout = null; } keyboardInteractiveFinish([""]); keyboardInteractiveFinish = null; warpgateAuthPromptSent = false; } break; } case "reconnect_with_credentials": { const credentialsData = data as { cols: number; rows: number; hostConfig: ConnectToHostData["hostConfig"]; password?: string; sshKey?: string; keyPassword?: string; }; if (credentialsData.password) { credentialsData.hostConfig.password = credentialsData.password; credentialsData.hostConfig.authType = "password"; ( credentialsData.hostConfig as Record ).userProvidedPassword = true; } else if (credentialsData.sshKey) { credentialsData.hostConfig.key = credentialsData.sshKey; credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; credentialsData.hostConfig.authType = "key"; } isAwaitingAuthCredentials = false; if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(); sshConn = null; sshStream = null; const reconnectData: ConnectToHostData = { cols: credentialsData.cols, rows: credentialsData.rows, hostConfig: credentialsData.hostConfig, }; handleConnectToHost(reconnectData).catch((error) => { sshLogger.error("Failed to reconnect with credentials", error, { operation: "ssh_reconnect_with_credentials", userId, hostId: credentialsData.hostConfig?.id, ip: credentialsData.hostConfig?.ip, }); ws.send( JSON.stringify({ type: "error", message: "Failed to connect with provided credentials: " + (error instanceof Error ? error.message : "Unknown error"), }), ); }); break; } case "opkssh_start_auth": { const opksshData = data as { hostId: number }; try { const { startOPKSSHAuth } = await import("./opkssh-auth.js"); const { getRequestOrigin } = await import("../utils/request-origin.js"); const db = getDb(); const hostRow = await db .select() .from(hosts) .where(eq(hosts.id, opksshData.hostId)) .limit(1); if (!hostRow || hostRow.length === 0) { sshLogger.error( `Host ${opksshData.hostId} not found for OPKSSH auth`, { operation: "opkssh_start_auth_host_not_found", userId, hostId: opksshData.hostId, }, ); ws.send( JSON.stringify({ type: "opkssh_error", requestId: "", error: "Host not found", }), ); break; } const hostname = hostRow[0].name || hostRow[0].ip; const requestOrigin = getRequestOrigin(req); await startOPKSSHAuth( userId, opksshData.hostId, hostname, ws, requestOrigin, ); } catch (error) { sshLogger.error("Failed to start OPKSSH auth", error, { operation: "opkssh_start_auth_error", userId, hostId: opksshData.hostId, }); ws.send( JSON.stringify({ type: "opkssh_error", requestId: "", error: "Failed to start OPKSSH authentication", }), ); } break; } case "opkssh_cancel": { const cancelData = data as { requestId: string }; try { const { cancelAuthSession } = await import("./opkssh-auth.js"); cancelAuthSession(cancelData.requestId); resetConnectionState(); } catch (error) { sshLogger.error("Failed to cancel OPKSSH auth", error, { operation: "opkssh_cancel_error", userId, }); } break; } case "opkssh_browser_opened": { break; } case "opkssh_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 OPKSSH auth", error, { operation: "opkssh_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", userId, messageType: type, }); } }); async function handleConnectToHost(data: ConnectToHostData) { const { hostConfig, initialPath, executeCommand } = data; const { id, ip: rawIp, port, username, password, key, keyPassword, keyType, authType, credentialId, } = hostConfig; const ip = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp; sshLogger.info("Resolving SSH host configuration", { operation: "terminal_host_resolve", sessionId, userId, hostId: id, }); const sendLog = ( stage: string, level: string, message: string, details?: Record, ) => { ws.send( JSON.stringify({ type: "connection_log", data: { stage, level, message, details }, }), ); }; if (!username || typeof username !== "string" || username.trim() === "") { sshLogger.error("Invalid username provided", undefined, { operation: "ssh_connect", hostId: id, ip, }); ws.send( JSON.stringify({ type: "error", message: "Invalid username provided" }), ); return; } if (!ip || typeof ip !== "string" || ip.trim() === "") { sshLogger.error("Invalid IP provided", undefined, { operation: "ssh_connect", hostId: id, username, }); ws.send( JSON.stringify({ type: "error", message: "Invalid IP provided" }), ); return; } if (!port || typeof port !== "number" || port <= 0) { sshLogger.error("Invalid port provided", undefined, { operation: "ssh_connect", hostId: id, ip, username, port, }); ws.send( JSON.stringify({ type: "error", message: "Invalid port provided" }), ); return; } if (isConnecting || isConnected) { sshLogger.warn("Connection already in progress or established", { operation: "ssh_connect", hostId: id, isConnecting, isConnected, }); ws.send( JSON.stringify({ type: "error", message: "Connection already in progress", code: "DUPLICATE_CONNECTION", }), ); return; } 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, { operation: "ssh_connect", hostId: id, ip, port, username, }); ws.send( JSON.stringify({ type: "error", message: "SSH connection timeout" }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); } }, 120000); let resolvedCredentials = { username, password, key, keyPassword, keyType, authType, }; const authMethodNotAvailable = false; if (credentialId && id) { const hostRow = await getDb() .select({ userId: hosts.userId }) .from(hosts) .where(eq(hosts.id, id)) .limit(1); const ownerId = hostRow[0]?.userId ?? null; if (ownerId && userId !== ownerId) { try { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( id, userId, ); if (sharedCred) { resolvedCredentials = { username: sharedCred.username || username, password: sharedCred.password, key: sharedCred.key, keyPassword: sharedCred.keyPassword, keyType: sharedCred.keyType, authType: sharedCred.authType, }; } else { sshLogger.warn(`No shared credentials found for host ${id}`, { operation: "ssh_credentials", userId, hostId: id, }); } } catch (error) { sshLogger.warn(`Failed to resolve shared credential for host ${id}`, { operation: "ssh_credentials", hostId: id, error: error instanceof Error ? error.message : "Unknown error", }); } } else if (ownerId) { try { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, ownerId), ), ), "ssh_credentials", ownerId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedCredentials = { username: (credential.username as string | undefined) || username, password: credential.password as string | undefined, key: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authType: credential.authType as string | undefined, }; } else { sshLogger.warn(`No credentials found for host ${id}`, { operation: "ssh_credentials", hostId: id, credentialId, userId: ownerId, }); } } catch (error) { sshLogger.warn(`Failed to resolve credentials for host ${id}`, { operation: "ssh_credentials", hostId: id, credentialId, error: error instanceof Error ? error.message : "Unknown error", }); } } else { sshLogger.warn("Missing userId for credential resolution in terminal", { operation: "ssh_credentials", hostId: id, credentialId, }); } } sshConn.on("ready", () => { clearTimeout(connectionTimeout); sshLogger.success("SSH connection established", { operation: "terminal_ssh_connected", sessionId, userId, hostId: id, ip, }); if (totpPromptSent) { authLogger.success("TOTP verification successful for SSH session", { operation: "terminal_totp_success", sessionId, userId, hostId: id, }); } sendLog("handshake", "success", "SSH handshake completed"); sendLog("auth", "success", `Authentication successful for ${username}`); sendLog("connected", "success", "Connection established"); const hostDisplayName = `${username}@${ip}:${port}`; const tabInstanceId = hostConfig.instanceId; currentSessionId = sessionManager.createSession( userId, id, hostDisplayName, data.cols, data.rows, tabInstanceId, ); sshLogger.info("Terminal session created after SSH ready", { operation: "terminal_session_created", sessionId: currentSessionId, userId, hostId: id, tabInstanceId, ip, port, }); const conn = sshConn; if (!conn || isCleaningUp || !sshConn) { sshLogger.warn( "SSH connection was cleaned up before shell could be created", { operation: "ssh_shell", hostId: id, ip, port, username, isCleaningUp, connNull: !conn, sshConnNull: !sshConn, }, ); ws.send( JSON.stringify({ type: "error", message: "SSH connection was closed before terminal could be created", }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } isShellInitializing = true; isConnecting = false; isConnected = true; if (!sshConn) { sshLogger.error( "SSH connection became null right before shell creation", { operation: "ssh_shell", hostId: id, }, ); ws.send( JSON.stringify({ type: "error", message: "SSH connection lost during setup", }), ); isShellInitializing = false; if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } sshLogger.info("Creating shell", { operation: "ssh_shell_start", hostId: id, ip, port, username, }); let shellCallbackReceived = false; const shellTimeout = setTimeout(() => { if (!shellCallbackReceived && isShellInitializing) { sshLogger.error("Shell creation timeout - no response from server", { operation: "ssh_shell_timeout", hostId: id, ip, port, username, }); isShellInitializing = false; ws.send( JSON.stringify({ type: "error", message: "Shell creation timeout. The server may not support interactive shells or the connection was interrupted.", }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); } }, 15000); conn.shell( { rows: data.rows, cols: data.cols, term: "xterm-256color", } as PseudoTtyOptions, (err, stream) => { shellCallbackReceived = true; clearTimeout(shellTimeout); isShellInitializing = false; if (err) { sshLogger.error("Shell error", err, { operation: "ssh_shell", hostId: id, ip, port, username, }); ws.send( JSON.stringify({ type: "error", message: "Shell error: " + err.message, }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } sshStream = stream; sshLogger.success("Terminal shell channel opened", { operation: "terminal_shell_opened", sessionId, userId, hostId: id, termType: "xterm-256color", }); if (currentSessionId) { sessionManager.setSSHState( currentSessionId, sshConn!, stream, lastJumpClient, opksshTempFiles, ); sessionManager.attachWs(currentSessionId, userId, ws); ws.send( JSON.stringify({ type: "sessionCreated", sessionId: currentSessionId, }), ); sshLogger.info("Session ready for persistence", { operation: "session_ready", sessionId: currentSessionId, userId, hostId: id, }); } const boundSessionId = currentSessionId; stream.on("data", (data: Buffer) => { try { const utf8String = data.toString("utf-8"); const session = sessionManager.getSession(boundSessionId); if (session) { sessionManager.bufferOutput(boundSessionId!, utf8String); if (session.attachedWs?.readyState === WebSocket.OPEN) { session.attachedWs.send( JSON.stringify({ type: "data", data: utf8String }), ); } } } catch (error) { sshLogger.error("Error encoding terminal data", error, { operation: "terminal_data_encoding", hostId: id, dataLength: data.length, }); const fallback = data.toString("latin1"); const session = sessionManager.getSession(boundSessionId); if (session) { sessionManager.bufferOutput(boundSessionId!, fallback); if (session.attachedWs?.readyState === WebSocket.OPEN) { session.attachedWs.send( JSON.stringify({ type: "data", data: fallback }), ); } } } }); stream.on("close", () => { const session = sessionManager.getSession(boundSessionId); if (session?.attachedWs?.readyState === WebSocket.OPEN) { session.attachedWs.send( JSON.stringify({ type: "disconnected", message: "Connection lost", }), ); } if (boundSessionId) { sessionManager.destroySession(boundSessionId); if (currentSessionId === boundSessionId) { currentSessionId = null; } } }); stream.on("error", (err: Error) => { sshLogger.error("SSH stream error", err, { operation: "ssh_stream", hostId: id, ip, port, username, }); const session = sessionManager.getSession(boundSessionId); if (session?.attachedWs?.readyState === WebSocket.OPEN) { session.attachedWs.send( JSON.stringify({ type: "error", message: "SSH stream error: " + err.message, }), ); } }); if (initialPath && initialPath.trim() !== "") { const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; stream.write(cdCommand); } if (executeCommand && executeCommand.trim() !== "") { setTimeout(() => { const command = `${executeCommand}\n`; stream.write(command); }, 500); } ws.send( JSON.stringify({ type: "connected", message: "SSH connected" }), ); if (id && hostConfig.userId) { (async () => { try { const hostResults = await SimpleDBOps.select( getDb() .select() .from(hosts) .where( and( eq(hosts.id, id), eq(hosts.userId, hostConfig.userId!), ), ), "ssh_data", hostConfig.userId!, ); const hostName = hostResults.length > 0 && hostResults[0].name ? hostResults[0].name : `${username}@${ip}:${port}`; await axios.post( "http://localhost:30006/activity/log", { type: "terminal", hostId: id, hostName, }, { headers: { Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`, }, }, ); } catch (error) { sshLogger.warn("Failed to log terminal activity", { operation: "activity_log_error", userId: hostConfig.userId, hostId: id, error: error instanceof Error ? error.message : "Unknown error", }); } })(); } }, ); }); sshConn.on("error", (err: Error) => { clearTimeout(connectionTimeout); sendLog("error", "error", `Connection error: ${err.message}`); sshLogger.error("SSH connection error", err, { operation: "ssh_connect", hostId: id, ip, port, username, authType: resolvedCredentials.authType, warpgateAuthPromptSent, isKeyboardInteractive, hasKeyboardInteractiveFinish: !!keyboardInteractiveFinish, keyboardInteractiveResponded, }); if ( resolvedCredentials.authType === "opkssh" && err.message.includes("All configured authentication methods failed") ) { sshLogger.warn("OPKSSH authentication failed - invalidating token", { operation: "opkssh_auth_failed", hostId: id, userId, error: err.message, }); (async () => { try { const { invalidateOPKSSHToken } = await import("./opkssh-auth.js"); await invalidateOPKSSHToken(userId, id, "SSH auth failed"); } catch (invalidateError) { sshLogger.error("Failed to invalidate OPKSSH token", { operation: "opkssh_token_invalidation_error", userId, hostId: id, error: invalidateError, }); } })(); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); sendLog( "auth", "error", "OPKSSH certificate authentication failed. Please authenticate again.", ); ws.send( JSON.stringify({ type: "opkssh_auth_required", hostId: id, message: "OPKSSH authentication failed or expired. Please authenticate again.", }), ); return; } if ( authMethodNotAvailable && resolvedCredentials.authType === "none" && !isKeyboardInteractive ) { sendLog( "auth", "error", "Server does not support keyboard-interactive authentication", ); isAwaitingAuthCredentials = true; if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); ws.send( JSON.stringify({ type: "auth_method_not_available", message: "The server does not support keyboard-interactive authentication. Please provide credentials.", }), ); return; } if ( resolvedCredentials.authType === "none" && err.message.includes("All configured authentication methods failed") && !isKeyboardInteractive && !keyboardInteractiveResponded ) { isAwaitingAuthCredentials = true; if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); ws.send( JSON.stringify({ type: "auth_method_not_available", message: "The server does not support keyboard-interactive authentication. Please provide credentials.", }), ); return; } if ( isKeyboardInteractive && keyboardInteractiveFinish && err.message.includes("All configured authentication methods failed") ) { sshLogger.warn( "Authentication error during keyboard-interactive - SKIPPING cleanup, waiting for user response", { operation: "ssh_error_during_keyboard_interactive_skip_cleanup", hostId: id, error: err.message, }, ); resetConnectionState(); return; } sshLogger.error("Proceeding with cleanup after error", { operation: "ssh_error_cleanup", hostId: id, error: err.message, }); if ( err.message.includes("authentication") || err.message.includes("Authentication") ) { authLogger.error("SSH authentication failed", err, { operation: "terminal_ssh_auth_failed", sessionId, userId, hostId: id, authType: resolvedCredentials.authType, }); sendLog("auth", "error", `Authentication failed: ${err.message}`); } else { sendLog("error", "error", `Connection failed: ${err.message}`); } let errorMessage = "SSH error: " + err.message; if (err.message.includes("No matching key exchange algorithm")) { errorMessage = "SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device."; } else if (err.message.includes("No matching cipher")) { errorMessage = "SSH error: No compatible cipher found. This may be due to an older SSH server or network device."; } else if (err.message.includes("No matching MAC")) { errorMessage = "SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device."; } else if ( err.message.includes("ENOTFOUND") || err.message.includes("ENOENT") ) { errorMessage = "SSH error: Could not resolve hostname or connect to server."; } else if (err.message.includes("ECONNREFUSED")) { errorMessage = "SSH error: Connection refused. The server may not be running or the port may be incorrect."; } else if (err.message.includes("ENETUNREACH")) { const isIPv6 = ip && ip.includes(":"); errorMessage = isIPv6 ? "SSH error: Network unreachable. IPv6 may not be available in this environment. If running in Docker, enable IPv6 in the Docker daemon and network configuration." : "SSH error: Network unreachable. Check your network configuration and routing."; } else if (err.message.includes("ETIMEDOUT")) { errorMessage = "SSH error: Connection timed out. Check your network connection and server availability."; } else if ( err.message.includes("ECONNRESET") || err.message.includes("EPIPE") ) { errorMessage = "SSH error: Connection was reset. This may be due to network issues or server timeout."; } else if ( err.message.includes("authentication failed") || err.message.includes("Permission denied") ) { errorMessage = "SSH error: Authentication failed. Please check your username and password/key."; } ws.send(JSON.stringify({ type: "error", message: errorMessage })); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); }); sshConn.on("close", () => { clearTimeout(connectionTimeout); sshLogger.info("SSH connection closed", { operation: "terminal_ssh_disconnected", sessionId, userId, hostId: id, }); if (isAwaitingAuthCredentials) { if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } if (isShellInitializing || (isConnected && !sshStream)) { sshLogger.warn("SSH connection closed during shell initialization", { operation: "ssh_close_during_init", hostId: id, ip, port, username, isShellInitializing, hasStream: !!sshStream, }); if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "error", message: "Connection closed during shell initialization. The server may have rejected the shell request.", }), ); } } else if (!sshStream) { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "disconnected", message: "Connection closed", }), ); } } if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); }); const sshAuthManager = new SSHAuthManager({ userId, ws, hostId: id || 0, isKeyboardInteractive, keyboardInteractiveResponded, keyboardInteractiveFinish, totpPromptSent, warpgateAuthPromptSent, totpTimeout, warpgateAuthTimeout, totpAttempts: 0, }); sshConn.on( "keyboard-interactive", ( name: string, instructions: string, instructionsLang: string, prompts: Array<{ prompt: string; echo: boolean }>, finish: (responses: string[]) => void, ) => { if (connectionTimeout) { clearTimeout(connectionTimeout); } sshAuthManager.handleKeyboardInteractive( name, instructions, instructionsLang, prompts, finish, resolvedCredentials as unknown as Parameters< typeof sshAuthManager.handleKeyboardInteractive >[5], ); isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive; keyboardInteractiveResponded = sshAuthManager.context.keyboardInteractiveResponded; keyboardInteractiveFinish = sshAuthManager.context.keyboardInteractiveFinish; totpPromptSent = sshAuthManager.context.totpPromptSent; warpgateAuthPromptSent = sshAuthManager.context.warpgateAuthPromptSent; totpTimeout = sshAuthManager.context.totpTimeout; warpgateAuthTimeout = sshAuthManager.context.warpgateAuthTimeout; }, ); const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval; const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax; const connectConfig: Record = { host: ip, port, username, tryKeyboard: true, keepaliveInterval: typeof hostKeepaliveInterval === "number" ? hostKeepaliveInterval : 30000, keepaliveCountMax: typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3, readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, timeout: 120000, hostVerifier: await SSHHostKeyVerifier.createHostVerifier( id, ip, port, ws, userId, false, ), env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", LC_MESSAGES: "en_US.UTF-8", LC_MONETARY: "en_US.UTF-8", LC_NUMERIC: "en_US.UTF-8", LC_TIME: "en_US.UTF-8", LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, algorithms: { kex: [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group18-sha512", "diffie-hellman-group17-sha512", "diffie-hellman-group16-sha512", "diffie-hellman-group15-sha512", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", ], serverHostKey: [ "ssh-ed25519", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ssh-dss", ], cipher: [ "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes128-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr", "aes256-cbc", "aes192-cbc", "aes128-cbc", "3des-cbc", ], hmac: [ "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], compress: ["none", "zlib@openssh.com", "zlib"], }, }; if (resolvedCredentials.authType === "none") { // no credentials needed } else if (resolvedCredentials.authType === "password") { if (!resolvedCredentials.password) { sshLogger.error( "Password authentication requested but no password provided", ); ws.send( JSON.stringify({ type: "error", message: "Password authentication requested but no password provided", }), ); return; } if (!hostConfig.forceKeyboardInteractive) { connectConfig.password = resolvedCredentials.password; } sendLog("auth", "info", "Using password authentication"); } else if ( resolvedCredentials.authType === "key" && resolvedCredentials.key ) { 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"); if (resolvedCredentials.keyPassword) { connectConfig.passphrase = resolvedCredentials.keyPassword; } } catch (keyError) { sshLogger.error("SSH key format error: " + keyError.message); ws.send( JSON.stringify({ type: "error", message: "SSH key format error: Invalid private key format", }), ); return; } } else if (resolvedCredentials.authType === "key") { sendLog( "auth", "error", "SSH key authentication requested but no key provided", ); sshLogger.error("SSH key authentication requested but no key provided"); ws.send( JSON.stringify({ type: "error", message: "SSH key authentication requested but no key provided", }), ); return; } else if (resolvedCredentials.authType === "opkssh") { sendLog("auth", "info", "Using OPKSSH certificate authentication"); try { const { getOPKSSHToken } = await import("./opkssh-auth.js"); const token = await getOPKSSHToken(userId, id); if (!token) { sendLog( "auth", "info", "No valid OPKSSH token found, requesting authentication", ); ws.send( JSON.stringify({ type: "opkssh_auth_required", hostId: id, }), ); return; } sendLog("auth", "info", "Using cached OPKSSH certificate"); const { promises: fs } = await import("fs"); const path = await import("path"); const os = await import("os"); const tempDir = os.tmpdir(); const keyPath = path.join(tempDir, `opkssh-${userId}-${id}`); const certPath = `${keyPath}-cert.pub`; await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 }); await fs.writeFile(certPath, token.sshCert, { mode: 0o600 }); opksshTempFiles = { keyPath, certPath }; connectConfig.privateKey = await fs.readFile(keyPath); } catch (opksshError) { sshLogger.error("OPKSSH authentication error", opksshError, { operation: "opkssh_auth_error", userId, hostId: id, }); ws.send( JSON.stringify({ type: "error", message: "OPKSSH authentication failed: " + (opksshError instanceof Error ? opksshError.message : "Unknown error"), }), ); return; } } else { sendLog("auth", "info", "Using keyboard-interactive authentication"); sshLogger.error("No valid authentication method provided"); ws.send( JSON.stringify({ type: "error", message: "No valid authentication method provided", }), ); return; } const proxyConfig: SOCKS5Config | null = hostConfig.useSocks5 && (hostConfig.socks5Host || (hostConfig.socks5ProxyChain && (hostConfig.socks5ProxyChain as ProxyNode[]).length > 0)) ? { useSocks5: hostConfig.useSocks5, socks5Host: hostConfig.socks5Host, socks5Port: hostConfig.socks5Port, socks5Username: hostConfig.socks5Username, socks5Password: hostConfig.socks5Password, socks5ProxyChain: hostConfig.socks5ProxyChain as ProxyNode[], } : null; const hasJumpHosts = hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0 && hostConfig.userId; if (hasJumpHosts) { try { const jumpClient = await createJumpHostChain( hostConfig.jumpHosts!, hostConfig.userId!, proxyConfig, ); if (!jumpClient) { sshLogger.error("Failed to establish jump host chain"); ws.send( JSON.stringify({ type: "error", message: "Failed to connect through jump hosts", }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } lastJumpClient = jumpClient; jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { if (err) { sshLogger.error("Failed to forward through jump host", err, { operation: "ssh_jump_forward", hostId: id, ip, port, }); ws.send( JSON.stringify({ type: "error", message: "Failed to forward through jump host: " + err.message, }), ); jumpClient.end(); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } connectConfig.sock = stream; sendLog( "handshake", "info", "Starting SSH session through jump host" + (proxyConfig ? " (via proxy)" : ""), ); sendLog("auth", "info", `Authenticating as ${username}`); sshLogger.info("Initiating SSH connection", { operation: "terminal_ssh_connect_attempt", sessionId, userId, hostId: id, ip, port, username, authType: resolvedCredentials.authType, viaProxy: !!proxyConfig, }); sshConn.connect(connectConfig); }); } catch (error) { sshLogger.error("Jump host error", error, { operation: "ssh_jump_host", hostId: id, }); ws.send( JSON.stringify({ type: "error", message: "Failed to connect through jump hosts", }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } } else if (proxyConfig) { try { const proxySocket = await createSocks5Connection(ip, port, proxyConfig); if (proxySocket) { connectConfig.sock = proxySocket; } } catch (proxyError) { sshLogger.error("Proxy connection failed", proxyError, { operation: "proxy_connect", hostId: id, proxyHost: hostConfig.socks5Host, proxyPort: hostConfig.socks5Port || 1080, }); ws.send( JSON.stringify({ type: "error", message: "Proxy connection failed: " + (proxyError instanceof Error ? proxyError.message : "Unknown error"), }), ); if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; } cleanupAuthState(connectionTimeout); return; } sendLog("handshake", "info", "Starting SSH session (via proxy)"); sendLog("auth", "info", `Authenticating as ${username}`); sshLogger.info("Initiating SSH connection", { operation: "terminal_ssh_connect_attempt", sessionId, userId, hostId: id, ip, port, username, authType: resolvedCredentials.authType, viaProxy: true, }); sshConn.connect(connectConfig); } else { sendLog("handshake", "info", "Starting SSH session"); sendLog("auth", "info", `Authenticating as ${username}`); sshLogger.info("Initiating SSH connection", { operation: "terminal_ssh_connect_attempt", sessionId, userId, hostId: id, ip, port, username, authType: resolvedCredentials.authType, }); sshConn.connect(connectConfig); } } function handleResize(data: ResizeData) { const resizeStream = sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream; if (resizeStream && resizeStream.setWindow) { resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols); const session = sessionManager.getSession(currentSessionId); if (session) { session.cols = data.cols; session.rows = data.rows; } ws.send( JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }), ); } } function cleanupAuthState(timeoutId?: NodeJS.Timeout) { if (timeoutId) { clearTimeout(timeoutId); } if (totpTimeout) { clearTimeout(totpTimeout); totpTimeout = null; } if (warpgateAuthTimeout) { clearTimeout(warpgateAuthTimeout); warpgateAuthTimeout = null; } sshStream = null; sshConn = null; lastJumpClient = null; opksshTempFiles = null; resetConnectionState(); isCleaningUp = false; isAwaitingAuthCredentials = false; } // Note: PTY-level keepalive (writing \x00 to the stream) was removed. // It was causing ^@ characters to appear in terminals with echoctl enabled. // SSH-level keepalive is configured via connectConfig (keepaliveInterval, // keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring // without producing visible output on the terminal. // // See: https://github.com/Termix-SSH/Support/issues/232 // See: https://github.com/Termix-SSH/Support/issues/309 }); ================================================ FILE: src/backend/ssh/tunnel.ts ================================================ import express, { type Response } from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import { Client } from "ssh2"; import { ChildProcess } from "child_process"; import axios from "axios"; import { getDb } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import type { SSHHost, TunnelConfig, TunnelStatus, VerificationData, ErrorType, AuthenticatedRequest, } from "../../types/index.js"; import { CONNECTION_STATES } from "../../types/index.js"; import { tunnelLogger } from "../utils/logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; 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"; const app = express(); app.use( cors({ origin: (origin, callback) => { if (!origin) return callback(null, true); const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"]; if (allowedOrigins.includes(origin)) { return callback(null, true); } if (origin.startsWith("https://")) { return callback(null, true); } if (origin.startsWith("http://")) { return callback(null, true); } callback(new Error("Not allowed by CORS")); }, credentials: true, methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], allowedHeaders: [ "Origin", "X-Requested-With", "Content-Type", "Accept", "Authorization", "User-Agent", "X-Electron-App", ], }), ); app.use(cookieParser()); app.use(express.json()); app.use((_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); const authManager = AuthManager.getInstance(); const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const activeTunnels = new Map(); const retryCounters = new Map(); const connectionStatus = new Map(); const tunnelVerifications = new Map(); const manualDisconnects = new Set(); const verificationTimers = new Map(); const activeRetryTimers = new Map(); const countdownIntervals = new Map(); const retryExhaustedTunnels = new Set(); const cleanupInProgress = new Set(); const tunnelConnecting = new Set(); const tunnelConfigs = new Map(); const activeTunnelProcesses = new Map(); const pendingTunnelOperations = new Map>(); function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void { if ( status.status === CONNECTION_STATES.CONNECTED && activeRetryTimers.has(tunnelName) ) { return; } if ( retryExhaustedTunnels.has(tunnelName) && status.status === CONNECTION_STATES.FAILED ) { status.reason = "Max retries exhausted"; } connectionStatus.set(tunnelName, status); } function getAllTunnelStatus(): Record { const tunnelStatus: Record = {}; connectionStatus.forEach((status, key) => { tunnelStatus[key] = status; }); return tunnelStatus; } function classifyError(errorMessage: string): ErrorType { if (!errorMessage) return "UNKNOWN"; const message = errorMessage.toLowerCase(); if ( message.includes("closed by remote host") || message.includes("connection reset by peer") || message.includes("connection refused") || message.includes("broken pipe") ) { return "NETWORK_ERROR"; } if ( message.includes("authentication failed") || message.includes("permission denied") || message.includes("incorrect password") ) { return "AUTHENTICATION_FAILED"; } if ( message.includes("connect etimedout") || message.includes("timeout") || message.includes("timed out") || message.includes("keepalive timeout") ) { return "TIMEOUT"; } if ( message.includes("bind: address already in use") || message.includes("failed for listen port") || message.includes("port forwarding failed") ) { return "CONNECTION_FAILED"; } if (message.includes("permission") || message.includes("access denied")) { return "CONNECTION_FAILED"; } return "UNKNOWN"; } function getTunnelMarker(tunnelName: string) { return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; } function normalizeTunnelName( hostId: number, tunnelIndex: number, displayName: string, sourcePort: number, endpointHost: string, endpointPort: number, ): string { return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`; } function parseTunnelName(tunnelName: string): { hostId?: number; tunnelIndex?: number; displayName: string; sourcePort: string; endpointHost: string; endpointPort: string; isLegacyFormat: boolean; } { const parts = tunnelName.split("::"); if (parts.length === 6) { return { hostId: parseInt(parts[0]), tunnelIndex: parseInt(parts[1]), displayName: parts[2], sourcePort: parts[3], endpointHost: parts[4], endpointPort: parts[5], isLegacyFormat: false, }; } tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`); const legacyParts = tunnelName.split("_"); return { displayName: legacyParts[0] || "unknown", sourcePort: legacyParts[legacyParts.length - 3] || "0", endpointHost: legacyParts[legacyParts.length - 2] || "unknown", endpointPort: legacyParts[legacyParts.length - 1] || "0", isLegacyFormat: true, }; } function validateTunnelConfig( tunnelName: string, tunnelConfig: TunnelConfig, ): boolean { const parsed = parseTunnelName(tunnelName); if (parsed.isLegacyFormat) { return true; } return ( parsed.hostId === tunnelConfig.sourceHostId && parsed.tunnelIndex === tunnelConfig.tunnelIndex && String(parsed.sourcePort) === String(tunnelConfig.sourcePort) && parsed.endpointHost === tunnelConfig.endpointHost && String(parsed.endpointPort) === String(tunnelConfig.endpointPort) ); } async function cleanupTunnelResources( tunnelName: string, forceCleanup = false, ): Promise { if (cleanupInProgress.has(tunnelName)) { return; } if (!forceCleanup && tunnelConnecting.has(tunnelName)) { return; } cleanupInProgress.add(tunnelName); const tunnelConfig = tunnelConfigs.get(tunnelName); if (tunnelConfig) { await new Promise((resolve) => { killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => { cleanupInProgress.delete(tunnelName); if (err) { tunnelLogger.error( `Failed to kill remote tunnel for '${tunnelName}': ${err.message}`, ); } resolve(); }); }); } else { cleanupInProgress.delete(tunnelName); } if (activeTunnelProcesses.has(tunnelName)) { try { const proc = activeTunnelProcesses.get(tunnelName); if (proc) { proc.kill("SIGTERM"); } } catch (e) { tunnelLogger.error( `Error while killing local ssh process for tunnel '${tunnelName}'`, e, ); } activeTunnelProcesses.delete(tunnelName); } if (activeTunnels.has(tunnelName)) { try { const conn = activeTunnels.get(tunnelName); if (conn) { conn.end(); } } catch (e) { tunnelLogger.error( `Error while closing SSH2 Client for tunnel '${tunnelName}'`, e, ); } activeTunnels.delete(tunnelName); } if (tunnelVerifications.has(tunnelName)) { const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); try { verification?.conn.end(); } catch (error) { tunnelLogger.error("Error during tunnel cleanup", error, { operation: "tunnel_cleanup_error", tunnelName, }); } tunnelVerifications.delete(tunnelName); } const timerKeys = [ tunnelName, `${tunnelName}_confirm`, `${tunnelName}_retry`, `${tunnelName}_verify_retry`, `${tunnelName}_ping`, ]; timerKeys.forEach((key) => { if (verificationTimers.has(key)) { clearTimeout(verificationTimers.get(key)!); verificationTimers.delete(key); } }); if (activeRetryTimers.has(tunnelName)) { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } if (countdownIntervals.has(tunnelName)) { clearInterval(countdownIntervals.get(tunnelName)!); countdownIntervals.delete(tunnelName); } } function resetRetryState(tunnelName: string): void { retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); cleanupInProgress.delete(tunnelName); tunnelConnecting.delete(tunnelName); if (activeRetryTimers.has(tunnelName)) { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } if (countdownIntervals.has(tunnelName)) { clearInterval(countdownIntervals.get(tunnelName)!); countdownIntervals.delete(tunnelName); } ["", "_confirm", "_retry", "_verify_retry", "_ping"].forEach((suffix) => { const timerKey = `${tunnelName}${suffix}`; if (verificationTimers.has(timerKey)) { clearTimeout(verificationTimers.get(timerKey)!); verificationTimers.delete(timerKey); } }); } async function handleDisconnect( tunnelName: string, tunnelConfig: TunnelConfig | null, shouldRetry = true, ): Promise { if (tunnelVerifications.has(tunnelName)) { try { const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); } catch (error) { tunnelLogger.error("Error during tunnel cleanup", error, { operation: "tunnel_cleanup_error", tunnelName, }); } tunnelVerifications.delete(tunnelName); } while (cleanupInProgress.has(tunnelName)) { await new Promise((resolve) => setTimeout(resolve, 100)); } await cleanupTunnelResources(tunnelName); if (manualDisconnects.has(tunnelName)) { resetRetryState(tunnelName); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.DISCONNECTED, manualDisconnect: true, }); return; } if (retryExhaustedTunnels.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "Max retries already exhausted", }); return; } if (activeRetryTimers.has(tunnelName)) { return; } if (shouldRetry && tunnelConfig) { const maxRetries = tunnelConfig.maxRetries || 3; const retryInterval = tunnelConfig.retryInterval || 5000; let retryCount = retryCounters.get(tunnelName) || 0; retryCount = retryCount + 1; if (retryCount > maxRetries) { tunnelLogger.error(`All ${maxRetries} retries failed for ${tunnelName}`); retryExhaustedTunnels.add(tunnelName); activeTunnels.delete(tunnelName); retryCounters.delete(tunnelName); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, retryExhausted: true, reason: `Max retries exhausted`, }); return; } retryCounters.set(tunnelName, retryCount); if (retryCount <= maxRetries) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.RETRYING, retryCount: retryCount, maxRetries: maxRetries, nextRetryIn: retryInterval / 1000, }); if (activeRetryTimers.has(tunnelName)) { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } const initialNextRetryIn = Math.ceil(retryInterval / 1000); let currentNextRetryIn = initialNextRetryIn; broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.WAITING, retryCount: retryCount, maxRetries: maxRetries, nextRetryIn: currentNextRetryIn, }); const countdownInterval = setInterval(() => { currentNextRetryIn--; if (currentNextRetryIn > 0) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.WAITING, retryCount: retryCount, maxRetries: maxRetries, nextRetryIn: currentNextRetryIn, }); } }, 1000); countdownIntervals.set(tunnelName, countdownInterval); const timer = setTimeout(() => { clearInterval(countdownInterval); countdownIntervals.delete(tunnelName); activeRetryTimers.delete(tunnelName); if (!manualDisconnects.has(tunnelName)) { activeTunnels.delete(tunnelName); connectSSHTunnel(tunnelConfig, retryCount).catch((error) => { tunnelLogger.error( `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, ); }); } }, retryInterval); activeRetryTimers.set(tunnelName, timer); } } else { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, }); activeTunnels.delete(tunnelName); } } function setupPingInterval(tunnelName: string): void { const pingKey = `${tunnelName}_ping`; if (verificationTimers.has(pingKey)) { clearInterval(verificationTimers.get(pingKey)!); verificationTimers.delete(pingKey); } const pingInterval = setInterval(() => { const currentStatus = connectionStatus.get(tunnelName); if (currentStatus?.status === CONNECTION_STATES.CONNECTED) { if (!activeTunnels.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.DISCONNECTED, reason: "Tunnel connection lost", }); clearInterval(pingInterval); verificationTimers.delete(pingKey); } } else { clearInterval(pingInterval); verificationTimers.delete(pingKey); } }, 120000); verificationTimers.set(pingKey, pingInterval); } async function connectSSHTunnel( tunnelConfig: TunnelConfig, retryAttempt = 0, ): Promise { const tunnelName = tunnelConfig.name; const tunnelMarker = getTunnelMarker(tunnelName); tunnelLogger.info("Tunnel creation request received", { operation: "tunnel_create_request", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, tunnelType: tunnelConfig.tunnelType || "remote", sourcePort: tunnelConfig.sourcePort, endpointHost: tunnelConfig.endpointHost, endpointPort: tunnelConfig.endpointPort, }); if (manualDisconnects.has(tunnelName)) { return; } tunnelConnecting.add(tunnelName); cleanupTunnelResources(tunnelName, true); if (retryAttempt === 0) { retryExhaustedTunnels.delete(tunnelName); retryCounters.delete(tunnelName); } const currentStatus = connectionStatus.get(tunnelName); if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.CONNECTING, retryCount: retryAttempt > 0 ? retryAttempt : undefined, }); } if ( !tunnelConfig || !tunnelConfig.sourceIP || !tunnelConfig.sourceUsername || !tunnelConfig.sourceSSHPort ) { const missingFields = []; if (!tunnelConfig) missingFields.push("tunnelConfig"); if (!tunnelConfig?.sourceIP) missingFields.push("sourceIP"); if (!tunnelConfig?.sourceUsername) missingFields.push("sourceUsername"); if (!tunnelConfig?.sourceSSHPort) missingFields.push("sourceSSHPort"); tunnelLogger.error("Invalid tunnel connection details", undefined, { operation: "tunnel_connect_validation_failed", tunnelName, missingFields: missingFields.join(", "), hasSourceIP: !!tunnelConfig?.sourceIP, hasSourceUsername: !!tunnelConfig?.sourceUsername, hasSourceSSHPort: !!tunnelConfig?.sourceSSHPort, }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "Missing required connection details", }); tunnelConnecting.delete(tunnelName); return; } let resolvedSourceCredentials = { password: tunnelConfig.sourcePassword, sshKey: tunnelConfig.sourceSSHKey, keyPassword: tunnelConfig.sourceKeyPassword, keyType: tunnelConfig.sourceKeyType, authMethod: tunnelConfig.sourceAuthMethod, }; const effectiveUserId = tunnelConfig.requestingUserId || tunnelConfig.sourceUserId; if (tunnelConfig.sourceCredentialId && effectiveUserId) { try { if ( tunnelConfig.requestingUserId && tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId ) { const { SharedCredentialManager } = await import("../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); if (tunnelConfig.sourceHostId) { const sharedCred = await sharedCredManager.getSharedCredentialForUser( tunnelConfig.sourceHostId, tunnelConfig.requestingUserId, ); if (sharedCred) { resolvedSourceCredentials = { password: sharedCred.password, sshKey: sharedCred.key, keyPassword: sharedCred.keyPassword, keyType: sharedCred.keyType, authMethod: sharedCred.authType, }; } else { const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`; tunnelLogger.error(errorMessage, undefined, { operation: "tunnel_shared_credentials_unavailable", tunnelName, requestingUserId: tunnelConfig.requestingUserId, sourceUserId: tunnelConfig.sourceUserId, sourceHostId: tunnelConfig.sourceHostId, }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: errorMessage, }); tunnelConnecting.delete(tunnelName); return; } } } else { const userDataKey = DataCrypto.getUserDataKey(effectiveUserId); if (userDataKey) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), "ssh_credentials", effectiveUserId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authMethod: credential.authType as string, }; } } } } catch (error) { tunnelLogger.warn("Failed to resolve source credentials", { operation: "tunnel_connect", tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : "Unknown error", }); } } let resolvedEndpointCredentials = { password: tunnelConfig.endpointPassword, sshKey: tunnelConfig.endpointSSHKey, keyPassword: tunnelConfig.endpointKeyPassword, keyType: tunnelConfig.endpointKeyType, authMethod: tunnelConfig.endpointAuthMethod, }; if ( resolvedEndpointCredentials.authMethod === "password" && !resolvedEndpointCredentials.password ) { const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires password authentication but no plaintext password available. Enable autostart for endpoint host or configure credentials in tunnel connection.`; tunnelLogger.error(errorMessage, undefined, { operation: "tunnel_endpoint_password_unavailable", tunnelName, endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`, endpointAuthMethod: resolvedEndpointCredentials.authMethod, }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: errorMessage, }); tunnelConnecting.delete(tunnelName); return; } if ( resolvedEndpointCredentials.authMethod === "key" && !resolvedEndpointCredentials.sshKey ) { const errorMessage = `Cannot connect tunnel '${tunnelName}': endpoint host requires key authentication but no plaintext key available. Enable autostart for endpoint host or configure credentials in tunnel connection.`; tunnelLogger.error(errorMessage, undefined, { operation: "tunnel_endpoint_key_unavailable", tunnelName, endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`, endpointAuthMethod: resolvedEndpointCredentials.authMethod, }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: errorMessage, }); tunnelConnecting.delete(tunnelName); return; } if (tunnelConfig.endpointCredentialId && tunnelConfig.endpointUserId) { try { const userDataKey = DataCrypto.getUserDataKey( tunnelConfig.endpointUserId, ); if (userDataKey) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)), "ssh_credentials", tunnelConfig.endpointUserId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedEndpointCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authMethod: credential.authType as string, }; } else { tunnelLogger.warn("No endpoint credentials found in database", { operation: "tunnel_connect", tunnelName, credentialId: tunnelConfig.endpointCredentialId, }); } } } catch (error) { tunnelLogger.warn( `Failed to resolve endpoint credentials for tunnel ${tunnelName}: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } else if (tunnelConfig.endpointCredentialId) { tunnelLogger.warn("Missing userId for endpoint credential resolution", { operation: "tunnel_connect", tunnelName, credentialId: tunnelConfig.endpointCredentialId, hasUserId: !!tunnelConfig.endpointUserId, }); } const conn = new Client(); const connectionTimeout = setTimeout(() => { if (conn) { if (activeRetryTimers.has(tunnelName)) { return; } tunnelLogger.error( `Tunnel connection timeout after 60 seconds for '${tunnelName}'`, undefined, { operation: "tunnel_connection_timeout", tunnelName, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`, retryAttempt, usingSocks5: tunnelConfig.useSocks5 || false, }, ); try { conn.end(); } catch { // expected } activeTunnels.delete(tunnelName); if (!activeRetryTimers.has(tunnelName)) { handleDisconnect( tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), ); } } }, 60000); conn.on("error", (err) => { clearTimeout(connectionTimeout); const errorType = classifyError(err.message); tunnelLogger.error(`Tunnel connection failed for '${tunnelName}'`, err, { operation: "tunnel_connect_error", tunnelName, errorType, errorMessage: err.message, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`, tunnelType: tunnelConfig.tunnelType || "remote", sourcePort: tunnelConfig.sourcePort, retryAttempt, usingSocks5: tunnelConfig.useSocks5 || false, authMethod: tunnelConfig.sourceAuthMethod, }); tunnelConnecting.delete(tunnelName); if (activeRetryTimers.has(tunnelName)) { return; } if (!manualDisconnects.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, errorType: errorType, reason: err.message, }); } activeTunnels.delete(tunnelName); const shouldNotRetry = errorType === "AUTHENTICATION_FAILED" || errorType === "CONNECTION_FAILED" || manualDisconnects.has(tunnelName); handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); }); conn.on("close", () => { clearTimeout(connectionTimeout); tunnelConnecting.delete(tunnelName); if (activeRetryTimers.has(tunnelName)) { return; } if (!manualDisconnects.has(tunnelName)) { const currentStatus = connectionStatus.get(tunnelName); if (!currentStatus || currentStatus.status !== CONNECTION_STATES.FAILED) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.DISCONNECTED, }); } if (!activeRetryTimers.has(tunnelName)) { handleDisconnect( tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), ); } } }); conn.on("ready", () => { clearTimeout(connectionTimeout); tunnelLogger.info("Creating new SSH connection for tunnel", { operation: "tunnel_connection_create", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, }); const isAlreadyVerifying = tunnelVerifications.has(tunnelName); if (isAlreadyVerifying) { return; } const tunnelType = tunnelConfig.tunnelType || "remote"; const tunnelFlag = tunnelType === "local" ? "-L" : "-R"; const portMapping = tunnelType === "local" ? `${tunnelConfig.sourcePort}:${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}` : `${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}`; let tunnelCmd: string; if ( resolvedEndpointCredentials.authMethod === "key" && resolvedEndpointCredentials.sshKey ) { const keyFilePath = `/tmp/tunnel_key_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`; tunnelCmd = `echo '${resolvedEndpointCredentials.sshKey}' > ${keyFilePath} && chmod 600 ${keyFilePath} && exec -a "${tunnelMarker}" ssh -i ${keyFilePath} -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP} && rm -f ${keyFilePath}`; } else { tunnelCmd = `exec -a "${tunnelMarker}" sshpass -p '${resolvedEndpointCredentials.password || ""}' ssh -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o GatewayPorts=yes ${tunnelFlag} ${portMapping} ${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}`; } conn.exec(tunnelCmd, (err, stream) => { if (err) { const errorType = classifyError(err.message); tunnelLogger.error( `Failed to execute tunnel command for '${tunnelName}'`, err, { operation: "tunnel_exec_error", tunnelName, errorType, errorMessage: err.message, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, endpointHost: `${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`, tunnelType: tunnelConfig.tunnelType || "remote", sourcePort: tunnelConfig.sourcePort, endpointPort: tunnelConfig.endpointPort, retryAttempt, }, ); conn.end(); activeTunnels.delete(tunnelName); const shouldNotRetry = errorType === "AUTHENTICATION_FAILED" || errorType === "CONNECTION_FAILED"; handleDisconnect(tunnelName, tunnelConfig, !shouldNotRetry); return; } activeTunnels.set(tunnelName, conn); tunnelLogger.success("Tunnel port binding successful", { operation: "tunnel_port_bound", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, sourcePort: tunnelConfig.sourcePort, endpointPort: tunnelConfig.endpointPort, }); setTimeout(() => { if ( !manualDisconnects.has(tunnelName) && activeTunnels.has(tunnelName) ) { tunnelConnecting.delete(tunnelName); tunnelLogger.success("Tunnel creation complete", { operation: "tunnel_create_complete", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, }); broadcastTunnelStatus(tunnelName, { connected: true, status: CONNECTION_STATES.CONNECTED, }); setupPingInterval(tunnelName); } }, 2000); stream.on("close", (code: number) => { if (activeRetryTimers.has(tunnelName)) { return; } activeTunnels.delete(tunnelName); if (tunnelVerifications.has(tunnelName)) { try { const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); } catch { // expected } tunnelVerifications.delete(tunnelName); } const isLikelyRemoteClosure = code === 255; if (isLikelyRemoteClosure && retryExhaustedTunnels.has(tunnelName)) { retryExhaustedTunnels.delete(tunnelName); } if ( !manualDisconnects.has(tunnelName) && code !== 0 && code !== undefined ) { if (retryExhaustedTunnels.has(tunnelName)) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "Max retries exhausted", }); } else { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: isLikelyRemoteClosure ? "Connection closed by remote host" : "Connection closed unexpectedly", }); } } if ( !activeRetryTimers.has(tunnelName) && !retryExhaustedTunnels.has(tunnelName) ) { handleDisconnect( tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName), ); } else if ( retryExhaustedTunnels.has(tunnelName) && isLikelyRemoteClosure ) { retryExhaustedTunnels.delete(tunnelName); retryCounters.delete(tunnelName); handleDisconnect(tunnelName, tunnelConfig, true); } }); stream.stdout?.on("data", () => {}); stream.on("error", () => {}); stream.stderr.on("data", (data) => { const errorMsg = data.toString().trim(); if (errorMsg) { const isDebugMessage = errorMsg.startsWith("debug1:") || errorMsg.startsWith("debug2:") || errorMsg.startsWith("debug3:") || errorMsg.includes("Reading configuration data") || errorMsg.includes("include /etc/ssh/ssh_config.d") || errorMsg.includes("matched no files") || errorMsg.includes("Applying options for"); if (!isDebugMessage) { tunnelLogger.error(`SSH stderr for '${tunnelName}': ${errorMsg}`); } if ( errorMsg.includes("sshpass: command not found") || errorMsg.includes("sshpass not found") ) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "sshpass tool not found on source host. Please install sshpass or use SSH key authentication.", }); } if ( errorMsg.includes("remote port forwarding failed") || errorMsg.includes("Error: remote port forwarding failed") ) { const portMatch = errorMsg.match(/listen port (\d+)/); const port = portMatch ? portMatch[1] : tunnelConfig.endpointPort; tunnelLogger.error( `Port forwarding failed for tunnel '${tunnelName}' on port ${port}. This prevents tunnel establishment.`, ); if (activeTunnels.has(tunnelName)) { const conn = activeTunnels.get(tunnelName); if (conn) { conn.end(); } activeTunnels.delete(tunnelName); } broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: `Remote port forwarding failed for port ${port}. Port may be in use, requires root privileges, or SSH server doesn't allow port forwarding. Try a different port.`, }); } } }); }); }); const connOptions: Record = { host: tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", LC_MESSAGES: "en_US.UTF-8", LC_MONETARY: "en_US.UTF-8", LC_NUMERIC: "en_US.UTF-8", LC_TIME: "en_US.UTF-8", LC_COLLATE: "en_US.UTF-8", COLORTERM: "truecolor", }, algorithms: { kex: [ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256", "diffie-hellman-group-exchange-sha256", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group-exchange-sha1", "diffie-hellman-group1-sha1", ], serverHostKey: [ "ssh-ed25519", "ecdsa-sha2-nistp521", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", "ssh-dss", ], cipher: [ "chacha20-poly1305@openssh.com", "aes256-gcm@openssh.com", "aes128-gcm@openssh.com", "aes256-ctr", "aes192-ctr", "aes128-ctr", "aes256-cbc", "aes192-cbc", "aes128-cbc", "3des-cbc", ], hmac: [ "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512", "hmac-sha2-256", "hmac-sha1", "hmac-md5", ], compress: ["none", "zlib@openssh.com", "zlib"], }, }; if ( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey ) { if ( !resolvedSourceCredentials.sshKey.includes("-----BEGIN") || !resolvedSourceCredentials.sshKey.includes("-----END") ) { tunnelLogger.error( `Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`, 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", }); 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; } if ( resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== "auto" ) { connOptions.privateKeyType = resolvedSourceCredentials.keyType; } } else if (resolvedSourceCredentials.authMethod === "key") { tunnelLogger.error( `SSH key authentication requested but no key provided for tunnel '${tunnelName}'`, undefined, { operation: "tunnel_ssh_key_missing", tunnelName, sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, authMethod: resolvedSourceCredentials.authMethod, }, ); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "SSH key authentication requested but no key provided", }); tunnelConnecting.delete(tunnelName); return; } else { connOptions.password = resolvedSourceCredentials.password; } const finalStatus = connectionStatus.get(tunnelName); if (!finalStatus || finalStatus.status !== CONNECTION_STATES.WAITING) { broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.CONNECTING, retryCount: retryAttempt > 0 ? retryAttempt : undefined, }); } if ( tunnelConfig.useSocks5 && (tunnelConfig.socks5Host || (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) ) { try { const socks5Socket = await createSocks5Connection( tunnelConfig.sourceIP, tunnelConfig.sourceSSHPort, { useSocks5: tunnelConfig.useSocks5, socks5Host: tunnelConfig.socks5Host, socks5Port: tunnelConfig.socks5Port, socks5Username: tunnelConfig.socks5Username, socks5Password: tunnelConfig.socks5Password, socks5ProxyChain: tunnelConfig.socks5ProxyChain, }, ); if (socks5Socket) { connOptions.sock = socks5Socket; conn.connect(connOptions); return; } } catch (socks5Error) { tunnelLogger.error("SOCKS5 connection failed for tunnel", socks5Error, { operation: "tunnel_socks5_connection_failed", tunnelName, sourceHost: `${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`, proxyHost: tunnelConfig.socks5Host, proxyPort: tunnelConfig.socks5Port || 1080, hasProxyAuth: !!( tunnelConfig.socks5Username && tunnelConfig.socks5Password ), errorMessage: socks5Error instanceof Error ? socks5Error.message : "Unknown error", }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.FAILED, reason: "SOCKS5 proxy connection failed: " + (socks5Error instanceof Error ? socks5Error.message : "Unknown error"), }); tunnelConnecting.delete(tunnelName); return; } } conn.connect(connOptions); } async function killRemoteTunnelByMarker( tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void, ) { const tunnelMarker = getTunnelMarker(tunnelName); tunnelLogger.info("Killing remote tunnel process", { operation: "tunnel_remote_kill", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, marker: tunnelMarker, }); let resolvedSourceCredentials = { password: tunnelConfig.sourcePassword, sshKey: tunnelConfig.sourceSSHKey, keyPassword: tunnelConfig.sourceKeyPassword, keyType: tunnelConfig.sourceKeyType, authMethod: tunnelConfig.sourceAuthMethod, }; if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) { try { const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId); if (userDataKey) { const credentials = await SimpleDBOps.select( getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)), "ssh_credentials", tunnelConfig.sourceUserId, ); if (credentials.length > 0) { const credential = credentials[0]; resolvedSourceCredentials = { password: credential.password as string | undefined, sshKey: credential.privateKey as string | undefined, keyPassword: credential.keyPassword as string | undefined, keyType: credential.keyType as string | undefined, authMethod: credential.authType as string, }; } } } catch (error) { tunnelLogger.warn("Failed to resolve source credentials for cleanup", { tunnelName, credentialId: tunnelConfig.sourceCredentialId, error: error instanceof Error ? error.message : "Unknown error", }); } } if ( resolvedSourceCredentials.authMethod === "key" && resolvedSourceCredentials.sshKey ) { if ( !resolvedSourceCredentials.sshKey.includes("-----BEGIN") || !resolvedSourceCredentials.sshKey.includes("-----END") ) { callback(new Error("Invalid SSH key format")); return; } } const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`; const factory = async (): Promise => { const connOptions: Record = { host: tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP, port: tunnelConfig.sourceSSHPort, username: tunnelConfig.sourceUsername, keepaliveInterval: 30000, keepaliveCountMax: 3, readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 15000, 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 ( 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"); if (resolvedSourceCredentials.keyPassword) { connOptions.passphrase = resolvedSourceCredentials.keyPassword; } if ( resolvedSourceCredentials.keyType && resolvedSourceCredentials.keyType !== "auto" ) { connOptions.privateKeyType = resolvedSourceCredentials.keyType; } } else { connOptions.password = resolvedSourceCredentials.password; } if ( tunnelConfig.useSocks5 && (tunnelConfig.socks5Host || (tunnelConfig.socks5ProxyChain && tunnelConfig.socks5ProxyChain.length > 0)) ) { try { const socks5Socket = await createSocks5Connection( tunnelConfig.sourceIP, tunnelConfig.sourceSSHPort, { useSocks5: tunnelConfig.useSocks5, socks5Host: tunnelConfig.socks5Host, socks5Port: tunnelConfig.socks5Port, socks5Username: tunnelConfig.socks5Username, socks5Password: tunnelConfig.socks5Password, socks5ProxyChain: tunnelConfig.socks5ProxyChain, }, ); if (socks5Socket) { connOptions.sock = socks5Socket; } else { throw new Error("Failed to create SOCKS5 connection"); } } catch (socks5Error) { tunnelLogger.error( "SOCKS5 connection failed for killing tunnel", socks5Error, { operation: "socks5_connect_kill", tunnelName, proxyHost: tunnelConfig.socks5Host, proxyPort: tunnelConfig.socks5Port || 1080, }, ); throw new Error( "SOCKS5 proxy connection failed: " + (socks5Error instanceof Error ? socks5Error.message : "Unknown error"), ); } } return new Promise((resolve, reject) => { const conn = new Client(); conn.on("ready", () => resolve(conn)); conn.on("error", (err) => reject(err)); conn.connect(connOptions); }); }; const execCommand = (client: Client, cmd: string): Promise => new Promise((resolve, reject) => { client.exec(cmd, (err, stream) => { if (err) { reject(err); return; } let output = ""; stream.on("data", (data: Buffer) => { output += data.toString(); }); stream.stderr.on("data", (data: Buffer) => { const stderr = data.toString().trim(); if (stderr && !stderr.includes("debug1")) { tunnelLogger.warn( `Kill command stderr for '${tunnelName}': ${stderr}`, ); } }); stream.on("close", () => resolve(output.trim())); }); }); try { await withConnection(poolKey, factory, async (client) => { const tunnelType = tunnelConfig.tunnelType || "remote"; const tunnelFlag = tunnelType === "local" ? "-L" : "-R"; const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`; const checkOutput = await execCommand(client, checkCmd); if (!checkOutput) { tunnelLogger.warn("Remote tunnel process not found", { operation: "tunnel_remote_not_found", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, marker: tunnelMarker, }); return; } tunnelLogger.info("Remote tunnel process found, proceeding to kill", { operation: "tunnel_remote_found", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, marker: tunnelMarker, }); const killCmds = [ `pkill -TERM -f '${tunnelMarker}'`, `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`, `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`, `sleep 2 && pkill -9 -f '${tunnelMarker}'`, ]; for (const killCmd of killCmds) { try { await execCommand(client, killCmd); } catch (err) { tunnelLogger.warn( `Kill command failed for '${tunnelName}': ${(err as Error).message}`, ); } } const verifyOutput = await execCommand(client, checkCmd); if (verifyOutput) { tunnelLogger.warn( `Some tunnel processes may still be running for '${tunnelName}'`, ); } else { tunnelLogger.success("Remote tunnel process killed", { operation: "tunnel_remote_killed", userId: tunnelConfig.sourceUserId, hostId: tunnelConfig.sourceHostId, tunnelName, }); } }); callback(); } catch (err) { tunnelLogger.error( `Failed to connect to source host for killing tunnel '${tunnelName}': ${(err as Error).message}`, ); callback(err as Error); } } /** * @openapi * /ssh/tunnel/status: * get: * summary: Get all tunnel statuses * description: Retrieves the status of all SSH tunnels. * tags: * - SSH Tunnels * responses: * 200: * description: A list of all tunnel statuses. */ app.get("/ssh/tunnel/status", (req, res) => { res.json(getAllTunnelStatus()); }); /** * @openapi * /ssh/tunnel/status/{tunnelName}: * get: * summary: Get tunnel status by name * description: Retrieves the status of a specific SSH tunnel by its name. * tags: * - SSH Tunnels * parameters: * - in: path * name: tunnelName * required: true * schema: * type: string * responses: * 200: * description: Tunnel status. * 404: * description: Tunnel not found. */ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { const { tunnelName } = req.params; const status = connectionStatus.get(tunnelName); if (!status) { return res.status(404).json({ error: "Tunnel not found" }); } res.json({ name: tunnelName, status }); }); /** * @openapi * /ssh/tunnel/connect: * post: * summary: Connect SSH tunnel * description: Establishes an SSH tunnel connection with the specified configuration. * tags: * - SSH Tunnels * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * sourceHostId: * type: integer * tunnelIndex: * type: integer * responses: * 200: * description: Connection request received. * 400: * description: Invalid tunnel configuration. * 401: * description: Authentication required. * 403: * description: Access denied to this host. * 500: * description: Failed to connect tunnel. */ app.post( "/ssh/tunnel/connect", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const tunnelConfig: TunnelConfig = req.body; const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } if (!tunnelConfig || !tunnelConfig.name) { return res.status(400).json({ error: "Invalid tunnel configuration" }); } const tunnelName = tunnelConfig.name; try { if (!validateTunnelConfig(tunnelName, tunnelConfig)) { tunnelLogger.error(`Tunnel config validation failed`, { operation: "tunnel_connect", tunnelName, configHostId: tunnelConfig.sourceHostId, configTunnelIndex: tunnelConfig.tunnelIndex, }); return res.status(400).json({ error: "Tunnel configuration does not match tunnel name", }); } if (tunnelConfig.sourceHostId) { const accessInfo = await permissionManager.canAccessHost( userId, tunnelConfig.sourceHostId, "read", ); if (!accessInfo.hasAccess) { tunnelLogger.warn("User attempted tunnel connect without access", { operation: "tunnel_connect_unauthorized", userId, hostId: tunnelConfig.sourceHostId, tunnelName, }); return res.status(403).json({ error: "Access denied to this host" }); } if (accessInfo.isShared && !accessInfo.isOwner) { tunnelConfig.requestingUserId = userId; } } if (pendingTunnelOperations.has(tunnelName)) { try { await pendingTunnelOperations.get(tunnelName); } catch { tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName }); } } const operation = (async () => { manualDisconnects.delete(tunnelName); retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); await cleanupTunnelResources(tunnelName); if (tunnelConfigs.has(tunnelName)) { const existingConfig = tunnelConfigs.get(tunnelName); if ( existingConfig && (existingConfig.sourceHostId !== tunnelConfig.sourceHostId || existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex) ) { throw new Error(`Tunnel name collision detected: ${tunnelName}`); } } if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) { try { const systemCrypto = SystemCrypto.getInstance(); const internalAuthToken = await systemCrypto.getInternalAuthToken(); 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: SSHHost[] = allHostsResponse.data || []; const endpointHost = allHosts.find( (h) => h.name === tunnelConfig.endpointHost || `${h.username}@${h.ip}` === 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.endpointPassword = endpointHost.password; tunnelConfig.endpointAuthMethod = endpointHost.authType; tunnelConfig.endpointSSHKey = endpointHost.key; tunnelConfig.endpointKeyPassword = endpointHost.keyPassword; tunnelConfig.endpointKeyType = endpointHost.keyType; tunnelConfig.endpointCredentialId = endpointHost.credentialId; tunnelConfig.endpointUserId = endpointHost.userId; } catch (resolveError) { tunnelLogger.error( "Failed to resolve endpoint host", resolveError, { operation: "tunnel_connect_resolve_endpoint_failed", tunnelName, endpointHost: tunnelConfig.endpointHost, }, ); throw new Error( `Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`, ); } } tunnelConfigs.set(tunnelName, tunnelConfig); await connectSSHTunnel(tunnelConfig, 0); })(); pendingTunnelOperations.set(tunnelName, operation); res.json({ message: "Connection request received", tunnelName }); operation.finally(() => { pendingTunnelOperations.delete(tunnelName); }); } catch (error) { tunnelLogger.error("Failed to process tunnel connect", error, { operation: "tunnel_connect", tunnelName, userId, }); res.status(500).json({ error: "Failed to connect tunnel" }); } }, ); /** * @openapi * /ssh/tunnel/disconnect: * post: * summary: Disconnect SSH tunnel * description: Disconnects an active SSH tunnel. * tags: * - SSH Tunnels * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * tunnelName: * type: string * responses: * 200: * description: Disconnect request received. * 400: * description: Tunnel name required. * 401: * description: Authentication required. * 403: * description: Access denied. * 500: * description: Failed to disconnect tunnel. */ app.post( "/ssh/tunnel/disconnect", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const { tunnelName } = req.body; const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } if (!tunnelName) { return res.status(400).json({ error: "Tunnel name required" }); } try { const config = tunnelConfigs.get(tunnelName); if (config && config.sourceHostId) { const accessInfo = await permissionManager.canAccessHost( userId, config.sourceHostId, "read", ); if (!accessInfo.hasAccess) { return res.status(403).json({ error: "Access denied" }); } } tunnelLogger.info("Tunnel stop request received", { operation: "tunnel_stop_request", userId, hostId: config?.sourceHostId, tunnelName, }); manualDisconnects.add(tunnelName); retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); if (activeRetryTimers.has(tunnelName)) { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } await cleanupTunnelResources(tunnelName, true); tunnelLogger.info("Tunnel cleanup completed", { operation: "tunnel_cleanup_complete", userId, hostId: config?.sourceHostId, tunnelName, }); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.DISCONNECTED, manualDisconnect: true, }); const tunnelConfig = tunnelConfigs.get(tunnelName) || null; handleDisconnect(tunnelName, tunnelConfig, false); setTimeout(() => { manualDisconnects.delete(tunnelName); }, 5000); res.json({ message: "Disconnect request received", tunnelName }); } catch (error) { tunnelLogger.error("Failed to disconnect tunnel", error, { operation: "tunnel_disconnect", tunnelName, userId, }); res.status(500).json({ error: "Failed to disconnect tunnel" }); } }, ); /** * @openapi * /ssh/tunnel/cancel: * post: * summary: Cancel tunnel retry * description: Cancels the retry mechanism for a failed SSH tunnel connection. * tags: * - SSH Tunnels * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * tunnelName: * type: string * responses: * 200: * description: Cancel request received. * 400: * description: Tunnel name required. * 401: * description: Authentication required. * 403: * description: Access denied. * 500: * description: Failed to cancel tunnel retry. */ app.post( "/ssh/tunnel/cancel", authenticateJWT, async (req: AuthenticatedRequest, res: Response) => { const { tunnelName } = req.body; const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } if (!tunnelName) { return res.status(400).json({ error: "Tunnel name required" }); } try { const config = tunnelConfigs.get(tunnelName); if (config && config.sourceHostId) { const accessInfo = await permissionManager.canAccessHost( userId, config.sourceHostId, "read", ); if (!accessInfo.hasAccess) { return res.status(403).json({ error: "Access denied" }); } } retryCounters.delete(tunnelName); retryExhaustedTunnels.delete(tunnelName); if (activeRetryTimers.has(tunnelName)) { clearTimeout(activeRetryTimers.get(tunnelName)!); activeRetryTimers.delete(tunnelName); } if (countdownIntervals.has(tunnelName)) { clearInterval(countdownIntervals.get(tunnelName)!); countdownIntervals.delete(tunnelName); } await cleanupTunnelResources(tunnelName, true); broadcastTunnelStatus(tunnelName, { connected: false, status: CONNECTION_STATES.DISCONNECTED, manualDisconnect: true, }); const tunnelConfig = tunnelConfigs.get(tunnelName) || null; handleDisconnect(tunnelName, tunnelConfig, false); setTimeout(() => { manualDisconnects.delete(tunnelName); }, 5000); res.json({ message: "Cancel request received", tunnelName }); } catch (error) { tunnelLogger.error("Failed to cancel tunnel retry", error, { operation: "tunnel_cancel", tunnelName, userId, }); res.status(500).json({ error: "Failed to cancel tunnel retry" }); } }, ); async function initializeAutoStartTunnels(): Promise { try { 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 allHostsResponse = await axios.get( "http://localhost:30001/host/db/host/internal/all", { headers: { "Content-Type": "application/json", "X-Internal-Auth-Token": 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`, ); for (const host of autostartHosts) { 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, ); if (endpointHost) { const tunnelIndex = host.tunnelConnections.indexOf(tunnelConnection); const tunnelConfig: TunnelConfig = { name: normalizeTunnelName( host.id, tunnelIndex, host.name || `${host.username}@${host.ip}`, tunnelConnection.sourcePort, tunnelConnection.endpointHost, tunnelConnection.endpointPort, ), tunnelType: tunnelConnection.tunnelType || "remote", sourceHostId: host.id, tunnelIndex: tunnelIndex, hostName: host.name || `${host.username}@${host.ip}`, sourceIP: host.ip, sourceSSHPort: host.port, sourceUsername: host.username, sourcePassword: host.autostartPassword || host.password, sourceAuthMethod: host.authType, sourceSSHKey: host.autostartKey || host.key, sourceKeyPassword: host.autostartKeyPassword || host.keyPassword, sourceKeyType: host.keyType, sourceCredentialId: host.credentialId, sourceUserId: host.userId, endpointIP: endpointHost.ip, endpointSSHPort: endpointHost.port, endpointUsername: endpointHost.username, endpointHost: tunnelConnection.endpointHost, endpointPassword: tunnelConnection.endpointPassword || endpointHost.autostartPassword || endpointHost.password, endpointAuthMethod: tunnelConnection.endpointAuthType || endpointHost.authType, endpointSSHKey: tunnelConnection.endpointKey || endpointHost.autostartKey || endpointHost.key, endpointKeyPassword: tunnelConnection.endpointKeyPassword || endpointHost.autostartKeyPassword || endpointHost.keyPassword, endpointKeyType: tunnelConnection.endpointKeyType || endpointHost.keyType, endpointCredentialId: endpointHost.credentialId, endpointUserId: endpointHost.userId, sourcePort: tunnelConnection.sourcePort, endpointPort: tunnelConnection.endpointPort, maxRetries: tunnelConnection.maxRetries, retryInterval: tunnelConnection.retryInterval * 1000, autoStart: tunnelConnection.autoStart, isPinned: host.pin, useSocks5: host.useSocks5, socks5Host: host.socks5Host, socks5Port: host.socks5Port, socks5Username: host.socks5Username, socks5Password: host.socks5Password, }; autoStartTunnels.push(tunnelConfig); } else { tunnelLogger.error( `Failed to find endpointHost '${tunnelConnection.endpointHost}' for tunnel from ${host.name || `${host.username}@${host.ip}`}. Available hosts: ${allHosts.map((h) => h.name || `${h.username}@${h.ip}`).join(", ")}`, ); } } } } } for (const tunnelConfig of autoStartTunnels) { tunnelConfigs.set(tunnelConfig.name, tunnelConfig); setTimeout(() => { connectSSHTunnel(tunnelConfig, 0).catch((error) => { tunnelLogger.error( `Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`, ); }); }, 1000); } } catch (error) { tunnelLogger.error( "Failed to initialize auto-start tunnels:", error instanceof Error ? error.message : "Unknown error", ); } } const PORT = 30003; app.listen(PORT, () => { setTimeout(() => { initializeAutoStartTunnels(); }, 2000); }); ================================================ FILE: src/backend/ssh/widgets/common-utils.ts ================================================ import type { Client, ClientChannel } from "ssh2"; export function execCommand( client: Client, command: string, timeoutMs = 30000, ): Promise<{ stdout: string; stderr: string; code: number | null; }> { return new Promise((resolve, reject) => { let settled = false; let stream: ClientChannel | null = null; const timeout = setTimeout(() => { if (!settled) { settled = true; cleanup(); reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`)); } }, timeoutMs); const cleanup = () => { clearTimeout(timeout); if (stream) { try { stream.removeAllListeners(); if (stream.stderr) { stream.stderr.removeAllListeners(); } stream.destroy(); } catch { // expected - cleanup errors ignored } } }; client.exec(command, { pty: false }, (err, _stream) => { if (err) { if (!settled) { settled = true; cleanup(); reject(err); } return; } stream = _stream; let stdout = ""; let stderr = ""; let exitCode: number | null = null; stream .on("close", (code: number | undefined) => { if (!settled) { settled = true; exitCode = typeof code === "number" ? code : null; cleanup(); resolve({ stdout, stderr, code: exitCode }); } }) .on("data", (data: Buffer) => { stdout += data.toString("utf8"); }) .on("error", (streamErr: Error) => { if (!settled) { settled = true; cleanup(); reject(streamErr); } }); if (stream.stderr) { stream.stderr .on("data", (data: Buffer) => { stderr += data.toString("utf8"); }) .on("error", (stderrErr: Error) => { if (!settled) { settled = true; cleanup(); reject(stderrErr); } }); } }); }); } export function toFixedNum( n: number | null | undefined, digits = 2, ): number | null { if (typeof n !== "number" || !Number.isFinite(n)) return null; return Number(n.toFixed(digits)); } export function kibToGiB(kib: number): number { return kib / (1024 * 1024); } ================================================ FILE: src/backend/ssh/widgets/cpu-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand, toFixedNum } from "./common-utils.js"; function parseCpuLine( cpuLine: string, ): { total: number; idle: number } | undefined { const parts = cpuLine.trim().split(/\s+/); if (parts[0] !== "cpu") return undefined; const nums = parts .slice(1) .map((n) => Number(n)) .filter((n) => Number.isFinite(n)); if (nums.length < 4) return undefined; const idle = (nums[3] ?? 0) + (nums[4] ?? 0); const total = nums.reduce((a, b) => a + b, 0); return { total, idle }; } export async function collectCpuMetrics(client: Client): Promise<{ percent: number | null; cores: number | null; load: [number, number, number] | null; }> { let cpuPercent: number | null = null; let cores: number | null = null; let loadTriplet: [number, number, number] | null = null; try { const [stat1, loadAvgOut, coresOut] = await Promise.race([ Promise.all([ execCommand(client, "cat /proc/stat"), execCommand(client, "cat /proc/loadavg"), execCommand( client, "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", ), ]), new Promise((_, reject) => setTimeout( () => reject(new Error("CPU metrics collection timeout")), 25000, ), ), ]); await new Promise((r) => setTimeout(r, 500)); const stat2 = await execCommand(client, "cat /proc/stat"); const cpuLine1 = ( stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" ).trim(); const cpuLine2 = ( stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" ).trim(); const a = parseCpuLine(cpuLine1); const b = parseCpuLine(cpuLine2); if (a && b) { const totalDiff = b.total - a.total; const idleDiff = b.idle - a.idle; const used = totalDiff - idleDiff; if (totalDiff > 0) cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); } const laParts = loadAvgOut.stdout.trim().split(/\s+/); if (laParts.length >= 3) { loadTriplet = [ Number(laParts[0]), Number(laParts[1]), Number(laParts[2]), ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ number, number, number, ]; } const coresNum = Number((coresOut.stdout || "").trim()); cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; } catch { cpuPercent = null; cores = null; loadTriplet = null; } return { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet, }; } ================================================ FILE: src/backend/ssh/widgets/disk-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand, toFixedNum } from "./common-utils.js"; export async function collectDiskMetrics(client: Client): Promise<{ percent: number | null; usedHuman: string | null; totalHuman: string | null; availableHuman: string | null; }> { let diskPercent: number | null = null; let usedHuman: string | null = null; let totalHuman: string | null = null; let availableHuman: string | null = null; try { const [diskOutHuman, diskOutBytes] = await Promise.all([ execCommand(client, "df -h -P / | tail -n +2"), execCommand(client, "df -B1 -P / | tail -n +2"), ]); const humanLine = diskOutHuman.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean)[0] || ""; const bytesLine = diskOutBytes.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean)[0] || ""; const humanParts = humanLine.split(/\s+/); const bytesParts = bytesLine.split(/\s+/); if (humanParts.length >= 6 && bytesParts.length >= 6) { totalHuman = humanParts[1] || null; usedHuman = humanParts[2] || null; availableHuman = humanParts[3] || null; const totalBytes = Number(bytesParts[1]); const usedBytes = Number(bytesParts[2]); if ( Number.isFinite(totalBytes) && Number.isFinite(usedBytes) && totalBytes > 0 ) { diskPercent = Math.max( 0, Math.min(100, (usedBytes / totalBytes) * 100), ); } } } catch { diskPercent = null; usedHuman = null; totalHuman = null; availableHuman = null; } return { percent: toFixedNum(diskPercent, 0), usedHuman, totalHuman, availableHuman, }; } ================================================ FILE: src/backend/ssh/widgets/firewall-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; import type { FirewallMetrics, FirewallChain, FirewallRule, } from "../../../types/stats-widgets.js"; function parseIptablesRule(line: string): FirewallRule | null { if (!line.startsWith("-A ")) return null; const rule: FirewallRule = { chain: "", target: "", protocol: "all", source: "0.0.0.0/0", destination: "0.0.0.0/0", }; const chainMatch = line.match(/^-A\s+(\S+)/); if (chainMatch) { rule.chain = chainMatch[1]; } const targetMatch = line.match(/-j\s+(\S+)/); if (targetMatch) { rule.target = targetMatch[1]; } const protocolMatch = line.match(/-p\s+(\S+)/); if (protocolMatch) { rule.protocol = protocolMatch[1]; } const sourceMatch = line.match(/-s\s+(\S+)/); if (sourceMatch) { rule.source = sourceMatch[1]; } const destMatch = line.match(/-d\s+(\S+)/); if (destMatch) { rule.destination = destMatch[1]; } const dportMatch = line.match(/--dport\s+(\S+)/); if (dportMatch) { rule.dport = dportMatch[1]; } const sportMatch = line.match(/--sport\s+(\S+)/); if (sportMatch) { rule.sport = sportMatch[1]; } const stateMatch = line.match(/--state\s+(\S+)/); if (stateMatch) { rule.state = stateMatch[1]; } const interfaceMatch = line.match(/-i\s+(\S+)/); if (interfaceMatch) { rule.interface = interfaceMatch[1]; } return rule; } function parseIptablesOutput(output: string): FirewallChain[] { const chains: Map = new Map(); const lines = output.split("\n"); for (const line of lines) { const trimmed = line.trim(); const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/); if (policyMatch) { const [, chainName, policy] = policyMatch; chains.set(chainName, { name: chainName, policy: policy, rules: [], }); continue; } const rule = parseIptablesRule(trimmed); if (rule) { let chain = chains.get(rule.chain); if (!chain) { chain = { name: rule.chain, policy: "ACCEPT", rules: [], }; chains.set(rule.chain, chain); } chain.rules.push(rule); } } return Array.from(chains.values()); } function parseNftablesOutput(output: string): FirewallChain[] { const chains: FirewallChain[] = []; let currentChain: FirewallChain | null = null; const lines = output.split("\n"); for (const line of lines) { const trimmed = line.trim(); const chainMatch = trimmed.match( /chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/, ); if (chainMatch) { if (currentChain) { chains.push(currentChain); } currentChain = { name: chainMatch[1].toUpperCase(), policy: "ACCEPT", rules: [], }; continue; } if (currentChain && trimmed.startsWith("policy ")) { const policyMatch = trimmed.match(/policy\s+(\S+)/); if (policyMatch) { currentChain.policy = policyMatch[1].toUpperCase(); } continue; } if (currentChain && trimmed && !trimmed.startsWith("}")) { const rule: FirewallRule = { chain: currentChain.name, target: "", protocol: "all", source: "0.0.0.0/0", destination: "0.0.0.0/0", }; if (trimmed.includes("accept")) rule.target = "ACCEPT"; else if (trimmed.includes("drop")) rule.target = "DROP"; else if (trimmed.includes("reject")) rule.target = "REJECT"; const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/); if (tcpMatch) { rule.protocol = "tcp"; rule.dport = tcpMatch[1]; } const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/); if (udpMatch) { rule.protocol = "udp"; rule.dport = udpMatch[1]; } const saddrMatch = trimmed.match(/saddr\s+(\S+)/); if (saddrMatch) { rule.source = saddrMatch[1]; } const daddrMatch = trimmed.match(/daddr\s+(\S+)/); if (daddrMatch) { rule.destination = daddrMatch[1]; } const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/); if (iifMatch) { rule.interface = iifMatch[1].replace(/"/g, ""); } const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/); if (ctStateMatch) { rule.state = ctStateMatch[1].toUpperCase(); } if (rule.target) { currentChain.rules.push(rule); } } if (trimmed === "}") { if (currentChain) { chains.push(currentChain); currentChain = null; } } } if (currentChain) { chains.push(currentChain); } return chains; } export async function collectFirewallMetrics( client: Client, ): Promise { try { const iptablesResult = await execCommand( client, "iptables-save 2>/dev/null", 15000, ); if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) { const chains = parseIptablesOutput(iptablesResult.stdout); const hasRules = chains.some((c) => c.rules.length > 0); return { type: "iptables", status: hasRules ? "active" : "inactive", chains: chains.filter( (c) => c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD", ), }; } const nftResult = await execCommand( client, "nft list ruleset 2>/dev/null", 15000, ); if (nftResult.stdout && nftResult.stdout.trim()) { const chains = parseNftablesOutput(nftResult.stdout); const hasRules = chains.some((c) => c.rules.length > 0); return { type: "nftables", status: hasRules ? "active" : "inactive", chains, }; } return { type: "none", status: "unknown", chains: [], }; } catch { return { type: "none", status: "unknown", chains: [], }; } } ================================================ FILE: src/backend/ssh/widgets/login-stats-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; export interface LoginRecord { user: string; ip: string; time: string; status: "success" | "failed"; } export interface LoginStats { recentLogins: LoginRecord[]; failedLogins: LoginRecord[]; totalLogins: number; uniqueIPs: number; } export async function collectLoginStats(client: Client): Promise { const recentLogins: LoginRecord[] = []; const failedLogins: LoginRecord[] = []; const ipSet = new Set(); try { const lastOut = await execCommand( client, "last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20", ); const lastLines = lastOut.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); for (const line of lastLines) { const parts = line.split(/\s+/); if (parts.length >= 10) { const user = parts[0]; const tty = parts[1]; const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2]; const timeStart = parts.indexOf( parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "", ); if (timeStart > 0 && parts.length > timeStart + 4) { const timeStr = parts.slice(timeStart, timeStart + 5).join(" "); if (user && user !== "wtmp" && tty !== "system") { let parsedTime: string; try { const date = new Date(timeStr); parsedTime = isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); } catch { parsedTime = new Date().toISOString(); } recentLogins.push({ user, ip, time: parsedTime, status: "success", }); if (ip !== "local") { ipSet.add(ip); } } } } } } catch { // expected } try { const failedOut = await execCommand( client, "grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''", ); const failedLines = failedOut.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); for (const line of failedLines) { let user = "unknown"; let ip = "unknown"; let timeStr = ""; const userMatch = line.match(/for (?:invalid user )?(\S+)/); if (userMatch) { user = userMatch[1]; } const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/); if (ipMatch) { ip = ipMatch[1]; } const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/); if (dateMatch) { const currentYear = new Date().getFullYear(); timeStr = `${currentYear} ${dateMatch[1]}`; } if (user && ip) { let parsedTime: string; try { const date = timeStr ? new Date(timeStr) : new Date(); parsedTime = isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); } catch { parsedTime = new Date().toISOString(); } failedLogins.push({ user, ip, time: parsedTime, status: "failed", }); if (ip !== "unknown") { ipSet.add(ip); } } } } catch { // expected } return { recentLogins: recentLogins.slice(0, 10), failedLogins: failedLogins.slice(0, 10), totalLogins: recentLogins.length, uniqueIPs: ipSet.size, }; } ================================================ FILE: src/backend/ssh/widgets/memory-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js"; export async function collectMemoryMetrics(client: Client): Promise<{ percent: number | null; usedGiB: number | null; totalGiB: number | null; }> { let memPercent: number | null = null; let usedGiB: number | null = null; let totalGiB: number | null = null; try { const memInfo = await execCommand(client, "cat /proc/meminfo"); const lines = memInfo.stdout.split("\n"); const getVal = (key: string) => { const line = lines.find((l) => l.startsWith(key)); if (!line) return null; const m = line.match(/\d+/); return m ? Number(m[0]) : null; }; const totalKb = getVal("MemTotal:"); const availKb = getVal("MemAvailable:"); if (totalKb && availKb && totalKb > 0) { const usedKb = totalKb - availKb; memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); usedGiB = kibToGiB(usedKb); totalGiB = kibToGiB(totalKb); } } catch { memPercent = null; usedGiB = null; totalGiB = null; } return { percent: toFixedNum(memPercent, 0), usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, }; } ================================================ FILE: src/backend/ssh/widgets/network-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; export async function collectNetworkMetrics(client: Client): Promise<{ interfaces: Array<{ name: string; ip: string; state: string; rxBytes: string | null; txBytes: string | null; }>; }> { const interfaces: Array<{ name: string; ip: string; state: string; rxBytes: string | null; txBytes: string | null; }> = []; try { const ifconfigOut = await execCommand( client, "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", ); const netStatOut = await execCommand( client, "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", ); const addrs = ifconfigOut.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); const states = netStatOut.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); const ifMap = new Map(); for (const line of addrs) { const parts = line.split(/\s+/); if (parts.length >= 2) { const name = parts[0]; const ip = parts[1].split("/")[0]; if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" }); } } for (const line of states) { const parts = line.split(/\s+/); if (parts.length >= 2) { const name = parts[0]; const state = parts[1]; const existing = ifMap.get(name); if (existing) { existing.state = state; } } } for (const [name, data] of ifMap.entries()) { interfaces.push({ name, ip: data.ip, state: data.state, rxBytes: null, txBytes: null, }); } } catch { // expected } return { interfaces }; } ================================================ FILE: src/backend/ssh/widgets/ports-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; import type { PortsMetrics, ListeningPort, } from "../../../types/stats-widgets.js"; function parseSsOutput(output: string): ListeningPort[] { const ports: ListeningPort[] = []; const lines = output.split("\n").slice(1); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const parts = trimmed.split(/\s+/); if (parts.length < 5) continue; const protocol = parts[0]?.toLowerCase(); if (protocol !== "tcp" && protocol !== "udp") continue; const state = parts[1]; const localAddr = parts[4]; if (!localAddr) continue; const lastColon = localAddr.lastIndexOf(":"); if (lastColon === -1) continue; const address = localAddr.substring(0, lastColon); const portStr = localAddr.substring(lastColon + 1); const port = parseInt(portStr, 10); if (isNaN(port)) continue; const portEntry: ListeningPort = { protocol: protocol as "tcp" | "udp", localAddress: address.replace(/^\[|\]$/g, ""), localPort: port, state: protocol === "tcp" ? state : undefined, }; const processInfo = parts[6]; if (processInfo && processInfo.startsWith("users:")) { const pidMatch = processInfo.match(/pid=(\d+)/); const nameMatch = processInfo.match(/\("([^"]+)"/); if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10); if (nameMatch) portEntry.process = nameMatch[1]; } ports.push(portEntry); } return ports; } function parseNetstatOutput(output: string): ListeningPort[] { const ports: ListeningPort[] = []; const lines = output.split("\n"); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; const parts = trimmed.split(/\s+/); if (parts.length < 4) continue; const proto = parts[0]?.toLowerCase(); if (!proto) continue; let protocol: "tcp" | "udp"; if (proto.startsWith("tcp")) { protocol = "tcp"; } else if (proto.startsWith("udp")) { protocol = "udp"; } else { continue; } const localAddr = parts[3]; if (!localAddr) continue; const lastColon = localAddr.lastIndexOf(":"); if (lastColon === -1) continue; const address = localAddr.substring(0, lastColon); const portStr = localAddr.substring(lastColon + 1); const port = parseInt(portStr, 10); if (isNaN(port)) continue; const portEntry: ListeningPort = { protocol, localAddress: address, localPort: port, }; if (protocol === "tcp" && parts.length >= 6) { portEntry.state = parts[5]; } const pidProgram = parts[parts.length - 1]; if (pidProgram && pidProgram.includes("/")) { const [pidStr, process] = pidProgram.split("/"); const pid = parseInt(pidStr, 10); if (!isNaN(pid)) portEntry.pid = pid; if (process) portEntry.process = process; } ports.push(portEntry); } return ports; } export async function collectPortsMetrics( client: Client, ): Promise { try { const ssResult = await execCommand(client, "ss -tulnp 2>/dev/null", 15000); if (ssResult.stdout && ssResult.stdout.includes("Local")) { const ports = parseSsOutput(ssResult.stdout); return { source: "ss", ports: ports.sort((a, b) => a.localPort - b.localPort), }; } const netstatResult = await execCommand( client, "netstat -tulnp 2>/dev/null", 15000, ); if (netstatResult.stdout && netstatResult.stdout.includes("Local")) { const ports = parseNetstatOutput(netstatResult.stdout); return { source: "netstat", ports: ports.sort((a, b) => a.localPort - b.localPort), }; } return { source: "none", ports: [], }; } catch { return { source: "none", ports: [], }; } } ================================================ FILE: src/backend/ssh/widgets/processes-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; export async function collectProcessesMetrics(client: Client): Promise<{ total: number | null; running: number | null; top: Array<{ pid: string; user: string; cpu: string; mem: string; command: string; }>; }> { let totalProcesses: number | null = null; let runningProcesses: number | null = null; const topProcesses: Array<{ pid: string; user: string; cpu: string; mem: string; command: string; }> = []; try { const psOut = await execCommand(client, "ps aux --sort=-%cpu | head -n 11"); const psLines = psOut.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); if (psLines.length > 1) { for (let i = 1; i < Math.min(psLines.length, 11); i++) { const parts = psLines[i].split(/\s+/); if (parts.length >= 11) { const cpuVal = Number(parts[2]); const memVal = Number(parts[3]); topProcesses.push({ pid: parts[1], user: parts[0], cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0", mem: Number.isFinite(memVal) ? memVal.toString() : "0", command: parts.slice(10).join(" ").substring(0, 50), }); } } } const procCount = await execCommand(client, "ps aux | wc -l"); const runningCount = await execCommand(client, "ps aux | grep -c ' R '"); const totalCount = Number(procCount.stdout.trim()) - 1; totalProcesses = Number.isFinite(totalCount) ? totalCount : null; const runningCount2 = Number(runningCount.stdout.trim()); runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null; } catch { // expected } return { total: totalProcesses, running: runningProcesses, top: topProcesses, }; } ================================================ FILE: src/backend/ssh/widgets/system-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; export async function collectSystemMetrics(client: Client): Promise<{ hostname: string | null; kernel: string | null; os: string | null; }> { let hostname: string | null = null; let kernel: string | null = null; let os: string | null = null; try { const hostnameOut = await execCommand(client, "hostname"); const kernelOut = await execCommand(client, "uname -r"); const osOut = await execCommand( client, "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2", ); hostname = hostnameOut.stdout.trim() || null; kernel = kernelOut.stdout.trim() || null; os = osOut.stdout.trim() || null; } catch { // expected } return { hostname, kernel, os, }; } ================================================ FILE: src/backend/ssh/widgets/uptime-collector.ts ================================================ import type { Client } from "ssh2"; import { execCommand } from "./common-utils.js"; export async function collectUptimeMetrics(client: Client): Promise<{ seconds: number | null; formatted: string | null; }> { let uptimeSeconds: number | null = null; let uptimeFormatted: string | null = null; try { const uptimeOut = await execCommand(client, "cat /proc/uptime"); const uptimeParts = uptimeOut.stdout.trim().split(/\s+/); if (uptimeParts.length >= 1) { uptimeSeconds = Number(uptimeParts[0]); if (Number.isFinite(uptimeSeconds)) { const days = Math.floor(uptimeSeconds / 86400); const hours = Math.floor((uptimeSeconds % 86400) / 3600); const minutes = Math.floor((uptimeSeconds % 3600) / 60); uptimeFormatted = `${days}d ${hours}h ${minutes}m`; } } } catch { // expected } return { seconds: uptimeSeconds, formatted: uptimeFormatted, }; } ================================================ FILE: src/backend/starter.ts ================================================ import dotenv from "dotenv"; import { promises as fs } from "fs"; import { readFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { AutoSSLSetup } from "./utils/auto-ssl-setup.js"; import { AuthManager } from "./utils/auth-manager.js"; import { DataCrypto } from "./utils/data-crypto.js"; import { SystemCrypto } from "./utils/system-crypto.js"; import { systemLogger, versionLogger } from "./utils/logger.js"; (async () => { const initStartTime = Date.now(); try { dotenv.config({ quiet: true }); const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); try { await fs.access(envPath); const persistentConfig = dotenv.config({ path: envPath, quiet: true }); if (persistentConfig.parsed) { Object.assign(process.env, persistentConfig.parsed); } } catch { // expected - env file may not exist } systemLogger.info("Termix backend initialization started", { operation: "backend_init_start", nodeEnv: process.env.NODE_ENV || "production", port: process.env.PORT || 4090, }); let version = "unknown"; const versionSources = [ () => process.env.VERSION, () => { try { const packageJsonPath = path.join(process.cwd(), "package.json"); const packageJson = JSON.parse( readFileSync(packageJsonPath, "utf-8"), ); return packageJson.version; } catch { return null; } }, () => { try { const __filename = fileURLToPath(import.meta.url); const packageJsonPath = path.join( path.dirname(__filename), "../../../package.json", ); const packageJson = JSON.parse( readFileSync(packageJsonPath, "utf-8"), ); return packageJson.version; } catch { return null; } }, () => { try { const packageJsonPath = path.join("/app", "package.json"); const packageJson = JSON.parse( readFileSync(packageJsonPath, "utf-8"), ); return packageJson.version; } catch { return null; } }, ]; for (const getVersion of versionSources) { try { const foundVersion = getVersion(); if (foundVersion && foundVersion !== "unknown") { version = foundVersion; break; } } catch { continue; } } versionLogger.info(`Termix Backend starting - Version: ${version}`, { operation: "startup", version: version, }); const systemCrypto = SystemCrypto.getInstance(); await systemCrypto.initializeJWTSecret(); await systemCrypto.initializeDatabaseKey(); await systemCrypto.initializeInternalAuthToken(); await AutoSSLSetup.initialize(); systemLogger.success("SSL setup completed", { operation: "backend_init_ssl", sslEnabled: process.env.SSL_ENABLED === "true", }); const dbModule = await import("./database/db/index.js"); await dbModule.initializeDatabase(); systemLogger.success("Database initialized", { operation: "backend_init_db", }); const authManager = AuthManager.getInstance(); await authManager.initialize(); DataCrypto.initialize(); const { OPKSSHBinaryManager } = await import("./utils/opkssh-binary-manager.js"); try { await OPKSSHBinaryManager.ensureBinary(); } catch (error) { const dataDir = process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); systemLogger.warn( "Failed to initialize OPKSSH binary - OPKSSH authentication will not be available", { operation: "opkssh_binary_init_failed", error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, platform: process.platform, arch: process.arch, dataDir, }, ); } await import("./database/database.js"); await import("./ssh/terminal.js"); await import("./ssh/tunnel.js"); await import("./ssh/file-manager.js"); await import("./ssh/server-stats.js"); await import("./ssh/docker.js"); await import("./ssh/docker-console.js"); await import("./dashboard.js"); // Initialize Guacamole server for RDP/VNC/Telnet support const { getDb: getDbForGuac } = await import("./database/db/index.js"); const guacDb = getDbForGuac(); const guacEnabledRow = guacDb.$client .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .get() as { value: string } | undefined; const guacEnabled = guacEnabledRow ? guacEnabledRow.value !== "false" : true; if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) { try { await import("./guacamole/guacamole-server.js"); systemLogger.info("Guacamole server initialized", { operation: "guac_init", }); } catch (error) { systemLogger.warn( "Failed to initialize Guacamole server (guacd may not be available)", { operation: "guac_init_skip", error: error instanceof Error ? error.message : "Unknown error", }, ); } } systemLogger.success("Termix backend started successfully", { operation: "backend_init_complete", port: process.env.PORT || 4090, ssl: process.env.SSL_ENABLED === "true", duration: Date.now() - initStartTime, }); process.on("SIGINT", () => { systemLogger.info( "Received SIGINT signal, initiating graceful shutdown...", { operation: "shutdown" }, ); process.exit(0); }); process.on("SIGTERM", () => { systemLogger.info( "Received SIGTERM signal, initiating graceful shutdown...", { operation: "shutdown" }, ); process.exit(0); }); process.on("message", (msg: { type?: string }) => { if (msg?.type === "shutdown") { systemLogger.info( "Received IPC shutdown, initiating graceful shutdown...", { operation: "shutdown" }, ); process.exit(0); } }); process.on("uncaughtException", (error) => { systemLogger.error("Uncaught exception occurred", error, { operation: "error_handling", }); process.exit(1); }); process.on("unhandledRejection", (reason) => { systemLogger.error("Unhandled promise rejection", reason, { operation: "error_handling", }); process.exit(1); }); } catch (error) { systemLogger.error("Failed to initialize backend services", error, { operation: "startup_failed", }); process.exit(1); } })(); ================================================ FILE: src/backend/swagger.ts ================================================ import swaggerJSDoc from "swagger-jsdoc"; import path from "path"; import { fileURLToPath } from "url"; import { promises as fs } from "fs"; import { systemLogger } from "./utils/logger.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.join(__dirname, "..", "..", ".."); const swaggerOptions: swaggerJSDoc.Options = { definition: { openapi: "3.0.3", info: { title: "Termix API", version: "0.0.0", description: "Termix Backend API Reference", }, servers: [ { url: "http://localhost:30001", description: "Main database and authentication server", }, { url: "http://localhost:30003", description: "SSH tunnel management server", }, { url: "http://localhost:30004", description: "SSH file manager server", }, { url: "http://localhost:30005", description: "Server statistics and monitoring server", }, { url: "http://localhost:30006", description: "Dashboard server", }, { url: "http://localhost:30007", description: "Docker management server", }, ], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", }, }, schemas: { Error: { type: "object", properties: { error: { type: "string" }, details: { type: "string" }, }, }, }, }, security: [ { bearerAuth: [], }, ], tags: [ { name: "Alerts", description: "System alerts and notifications management", }, { name: "Credentials", description: "SSH credential management", }, { name: "Network Topology", description: "Network topology visualization and management", }, { name: "RBAC", description: "Role-based access control for host sharing", }, { name: "Snippets", description: "Command snippet management", }, { name: "Terminal", description: "Terminal command history", }, { name: "Users", description: "User management and authentication", }, { name: "Dashboard", description: "Dashboard statistics and activity", }, { name: "Docker", description: "Docker container management", }, { name: "SSH Tunnels", description: "SSH tunnel connection management", }, { name: "Server Stats", description: "Server status monitoring and metrics collection", }, { name: "File Manager", description: "SSH file management operations", }, ], }, apis: [ path.join(projectRoot, "src", "backend", "database", "routes", "*.ts"), path.join(projectRoot, "src", "backend", "dashboard.ts"), path.join(projectRoot, "src", "backend", "ssh", "*.ts"), ], }; async function generateOpenAPISpec() { try { systemLogger.info("Generating OpenAPI specification", { operation: "openapi_generate_start", }); const swaggerSpec = swaggerJSDoc(swaggerOptions); const outputPath = path.join(projectRoot, "openapi.json"); await fs.writeFile( outputPath, JSON.stringify(swaggerSpec, null, 2), "utf-8", ); systemLogger.success("OpenAPI specification generated", { operation: "openapi_generate_success", }); } catch (error) { systemLogger.error("Failed to generate OpenAPI specification", error, { operation: "openapi_generation", }); process.exit(1); } } generateOpenAPISpec(); export { swaggerOptions, generateOpenAPISpec }; ================================================ FILE: src/backend/utils/auth-manager.ts ================================================ import jwt from "jsonwebtoken"; import { UserCrypto } from "./user-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger, authLogger } from "./logger.js"; import type { Request, Response, NextFunction } from "express"; import { db } from "../database/db/index.js"; import { sessions, trustedDevices } from "../database/db/schema.js"; import { eq, and, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import type { DeviceType } from "./user-agent-parser.js"; interface AuthenticationResult { success: boolean; token?: string; userId?: string; isAdmin?: boolean; username?: string; requiresTOTP?: boolean; tempToken?: string; error?: string; } interface JWTPayload { userId: string; sessionId?: string; pendingTOTP?: boolean; iat?: number; exp?: number; } interface AuthenticatedRequest extends Request { userId?: string; pendingTOTP?: boolean; dataKey?: Buffer; } interface RequestWithHeaders extends Request { headers: Request["headers"] & { "x-forwarded-proto"?: string; }; } class AuthManager { private static instance: AuthManager; private systemCrypto: SystemCrypto; private userCrypto: UserCrypto; private constructor() { this.systemCrypto = SystemCrypto.getInstance(); this.userCrypto = UserCrypto.getInstance(); this.userCrypto.setSessionExpiredCallback((userId: string) => { this.invalidateUserTokens(userId); }); setInterval( () => { this.cleanupExpiredSessions().catch((error) => { databaseLogger.error( "Failed to run periodic session cleanup", error, { operation: "session_cleanup_periodic", }, ); }); }, 5 * 60 * 1000, ); } static getInstance(): AuthManager { if (!this.instance) { this.instance = new AuthManager(); } return this.instance; } async initialize(): Promise { await this.systemCrypto.initializeJWTSecret(); } async registerUser(userId: string, password: string): Promise { await this.userCrypto.setupUserEncryption(userId, password); } async registerOIDCUser( userId: string, sessionDurationMs: number, ): Promise { await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs); } async authenticateOIDCUser( 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.authenticateOIDCUser( userId, sessionDurationMs, ); if (authenticated) { await this.performLazyEncryptionMigration(userId); } return authenticated; } async authenticateUser( userId: string, password: string, deviceType?: DeviceType, ): Promise { const sessionDurationMs = deviceType === "desktop" || deviceType === "mobile" ? 30 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000; const authenticated = await this.userCrypto.authenticateUser( userId, password, sessionDurationMs, ); if (authenticated) { await this.performLazyEncryptionMigration(userId); } return authenticated; } async convertToOIDCEncryption(userId: string): Promise { await this.userCrypto.convertToOIDCEncryption(userId); } private async performLazyEncryptionMigration(userId: string): Promise { try { const userDataKey = this.getUserDataKey(userId); if (!userDataKey) { databaseLogger.warn( "Cannot perform lazy encryption migration - user data key not available", { operation: "lazy_encryption_migration_no_key", userId, }, ); return; } const { getSqlite, saveMemoryDatabaseToFile } = await import("../database/db/index.js"); const sqlite = getSqlite(); const migrationResult = await DataCrypto.migrateUserSensitiveFields( userId, userDataKey, sqlite, ); if (migrationResult.migrated) { await saveMemoryDatabaseToFile(); } try { const { CredentialSystemEncryptionMigration } = await import("./credential-system-encryption-migration.js"); const credMigration = new CredentialSystemEncryptionMigration(); const credResult = await credMigration.migrateUserCredentials(userId); if (credResult.migrated > 0) { await saveMemoryDatabaseToFile(); } } catch (error) { databaseLogger.warn("Credential migration failed during login", { operation: "login_credential_migration_failed", userId, error: error instanceof Error ? error.message : "Unknown error", }); } } catch (error) { databaseLogger.error("Lazy encryption migration failed", error, { operation: "lazy_encryption_migration_error", userId, error: error instanceof Error ? error.message : "Unknown error", }); } } async generateJWTToken( userId: string, options: { expiresIn?: string; pendingTOTP?: boolean; rememberMe?: boolean; deviceType?: DeviceType; deviceInfo?: string; } = {}, ): Promise { const jwtSecret = await this.systemCrypto.getJWTSecret(); let expiresIn = options.expiresIn; if (!expiresIn && !options.pendingTOTP) { if (options.rememberMe) { expiresIn = "30d"; } else { expiresIn = "24h"; } } else if (!expiresIn) { expiresIn = "24h"; } const payload: JWTPayload = { userId }; if (options.pendingTOTP) { payload.pendingTOTP = true; } if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { const sessionId = nanoid(); payload.sessionId = sessionId; const token = jwt.sign(payload, jwtSecret, { expiresIn, } as jwt.SignOptions); const expirationMs = this.parseExpiresIn(expiresIn); const now = new Date(); const expiresAt = new Date(now.getTime() + expirationMs).toISOString(); const createdAt = now.toISOString(); try { await db.insert(sessions).values({ id: sessionId, userId, jwtToken: token, deviceType: options.deviceType, deviceInfo: options.deviceInfo, createdAt, expiresAt, lastActiveAt: createdAt, }); try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( "Failed to save database after session creation", saveError, { operation: "session_create_db_save_failed", sessionId, }, ); } } catch (error) { databaseLogger.error("Failed to create session", error, { operation: "session_create_failed", userId, sessionId, }); } return token; } return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); } private parseExpiresIn(expiresIn: string): number { const match = expiresIn.match(/^(\d+)([smhd])$/); if (!match) return 24 * 60 * 60 * 1000; const value = parseInt(match[1]); const unit = match[2]; switch (unit) { case "s": return value * 1000; case "m": return value * 60 * 1000; case "h": return value * 60 * 60 * 1000; case "d": return value * 24 * 60 * 60 * 1000; default: return 24 * 60 * 60 * 1000; } } async verifyJWTToken(token: string): Promise { try { const jwtSecret = await this.systemCrypto.getJWTSecret(); const payload = jwt.verify(token, jwtSecret) as JWTPayload; if (payload.sessionId) { try { const sessionRecords = await db .select() .from(sessions) .where(eq(sessions.id, payload.sessionId)) .limit(1); if (sessionRecords.length === 0) { databaseLogger.warn("Session not found during JWT verification", { operation: "jwt_verify_session_not_found", sessionId: payload.sessionId, userId: payload.userId, }); return null; } } catch (dbError) { databaseLogger.error( "Failed to check session in database during JWT verification", dbError, { operation: "jwt_verify_session_check_failed", sessionId: payload.sessionId, }, ); return null; } } return payload; } catch (error) { databaseLogger.warn("JWT verification failed", { operation: "jwt_verify_failed", error: error instanceof Error ? error.message : "Unknown error", errorName: error instanceof Error ? error.name : "Unknown", }); return null; } } // eslint-disable-next-line @typescript-eslint/no-unused-vars invalidateJWTToken(_token: string): void { // expected - no-op, JWT tokens are stateless } // eslint-disable-next-line @typescript-eslint/no-unused-vars invalidateUserTokens(_userId: string): void { // expected - no-op, handled by session management } async revokeSession(sessionId: string): Promise { try { authLogger.info("User session invalidated", { operation: "user_logout", sessionId, }); await db.delete(sessions).where(eq(sessions.id, sessionId)); try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( "Failed to save database after session revocation", saveError, { operation: "session_revoke_db_save_failed", sessionId, }, ); } return true; } catch (error) { databaseLogger.error("Failed to delete session", error, { operation: "session_delete_failed", sessionId, }); return false; } } async revokeAllUserSessions( userId: string, exceptSessionId?: string, ): Promise { try { const userSessions = await db .select() .from(sessions) .where(eq(sessions.userId, userId)); const deletedCount = userSessions.filter( (s) => !exceptSessionId || s.id !== exceptSessionId, ).length; authLogger.info("All user sessions invalidated", { operation: "user_logout_all", userId, sessionCount: deletedCount, }); if (exceptSessionId) { await db .delete(sessions) .where( and( eq(sessions.userId, userId), sql`${sessions.id} != ${exceptSessionId}`, ), ); } else { await db.delete(sessions).where(eq(sessions.userId, userId)); } try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( "Failed to save database after revoking all user sessions", saveError, { operation: "user_sessions_revoke_db_save_failed", userId, }, ); } return deletedCount; } catch (error) { databaseLogger.error("Failed to delete user sessions", error, { operation: "user_sessions_delete_failed", userId, }); return 0; } } async cleanupExpiredSessions(): Promise { try { const expiredSessions = await db .select() .from(sessions) .where(sql`${sessions.expiresAt} < datetime('now')`); const expiredCount = expiredSessions.length; if (expiredCount === 0) { return 0; } await db .delete(sessions) .where(sql`${sessions.expiresAt} < datetime('now')`); try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( "Failed to save database after cleaning up expired sessions", saveError, { operation: "sessions_cleanup_db_save_failed", }, ); } const affectedUsers = new Set(expiredSessions.map((s) => s.userId)); for (const userId of affectedUsers) { const remainingSessions = await db .select() .from(sessions) .where(eq(sessions.userId, userId)); if (remainingSessions.length === 0) { this.userCrypto.logoutUser(userId); } } return expiredCount; } catch (error) { databaseLogger.error("Failed to cleanup expired sessions", error, { operation: "sessions_cleanup_failed", }); return 0; } } async getAllSessions(): Promise[]> { try { const allSessions = await db.select().from(sessions); return allSessions; } catch (error) { databaseLogger.error("Failed to get all sessions", error, { operation: "sessions_get_all_failed", }); return []; } } async getUserSessions(userId: string): Promise[]> { try { const userSessions = await db .select() .from(sessions) .where(eq(sessions.userId, userId)); return userSessions; } catch (error) { databaseLogger.error("Failed to get user sessions", error, { operation: "sessions_get_user_failed", userId, }); return []; } } getSecureCookieOptions( req: RequestWithHeaders, maxAge: number = 24 * 60 * 60 * 1000, ) { return { httpOnly: false, secure: req.secure || req.headers["x-forwarded-proto"] === "https", sameSite: "strict" as const, maxAge: maxAge, path: "/", }; } getClearCookieOptions(req: RequestWithHeaders) { return { httpOnly: false, secure: req.secure || req.headers["x-forwarded-proto"] === "https", sameSite: "strict" as const, path: "/", }; } createAuthMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; let token = authReq.cookies?.jwt; if (!token) { const authHeader = authReq.headers["authorization"]; if (authHeader?.startsWith("Bearer ")) { token = authHeader.split(" ")[1]; } } if (!token) { return res.status(401).json({ error: "Missing authentication token" }); } const payload = await this.verifyJWTToken(token); if (!payload) { return res.status(401).json({ error: "Invalid token" }); } if (payload.sessionId) { try { const sessionRecords = await db .select() .from(sessions) .where(eq(sessions.id, payload.sessionId)) .limit(1); if (sessionRecords.length === 0) { databaseLogger.warn("Session not found in middleware", { operation: "middleware_session_not_found", sessionId: payload.sessionId, userId: payload.userId, }); return res.status(401).json({ error: "Session not found", code: "SESSION_NOT_FOUND", }); } const session = sessionRecords[0]; const sessionExpiryTime = new Date(session.expiresAt).getTime(); const currentTime = Date.now(); const isExpired = sessionExpiryTime < currentTime; if (isExpired) { databaseLogger.warn("Session has expired", { operation: "session_expired", sessionId: payload.sessionId, expiresAt: session.expiresAt, expiryTime: sessionExpiryTime, currentTime: currentTime, difference: currentTime - sessionExpiryTime, }); db.delete(sessions) .where(eq(sessions.id, payload.sessionId)) .then(async () => { try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); const remainingSessions = await db .select() .from(sessions) .where(eq(sessions.userId, payload.userId)); if (remainingSessions.length === 0) { this.userCrypto.logoutUser(payload.userId); } } catch (cleanupError) { databaseLogger.error( "Failed to cleanup after expired session", cleanupError, { operation: "expired_session_cleanup_failed", sessionId: payload.sessionId, }, ); } }) .catch((error) => { databaseLogger.error( "Failed to delete expired session", error, { operation: "expired_session_delete_failed", sessionId: payload.sessionId, }, ); }); return res.status(401).json({ error: "Session has expired", code: "SESSION_EXPIRED", }); } db.update(sessions) .set({ lastActiveAt: new Date().toISOString() }) .where(eq(sessions.id, payload.sessionId)) .then(() => {}) .catch((error) => { databaseLogger.warn("Failed to update session lastActiveAt", { operation: "session_update_last_active", sessionId: payload.sessionId, error: error instanceof Error ? error.message : "Unknown error", }); }); } catch (error) { databaseLogger.error("Session check failed in middleware", error, { operation: "middleware_session_check_failed", sessionId: payload.sessionId, }); return res.status(500).json({ error: "Session check failed" }); } } authReq.userId = payload.userId; authReq.pendingTOTP = payload.pendingTOTP; next(); }; } createDataAccessMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; const userId = authReq.userId; if (!userId) { return res.status(401).json({ error: "Authentication required" }); } const dataKey = this.userCrypto.getUserDataKey(userId); authReq.dataKey = dataKey || undefined; next(); }; } createAdminMiddleware() { return async (req: Request, res: Response, next: NextFunction) => { let token = req.cookies?.jwt; if (!token) { const authHeader = req.headers["authorization"]; if (authHeader?.startsWith("Bearer ")) { token = authHeader.split(" ")[1]; } } if (!token) { return res.status(401).json({ error: "Missing authentication token" }); } const payload = await this.verifyJWTToken(token); if (!payload) { return res.status(401).json({ error: "Invalid token" }); } try { const { db } = await import("../database/db/index.js"); const { users } = await import("../database/db/schema.js"); const { eq } = await import("drizzle-orm"); const user = await db .select() .from(users) .where(eq(users.id, payload.userId)); if (!user || user.length === 0 || !user[0].isAdmin) { databaseLogger.warn( "Non-admin user attempted to access admin endpoint", { operation: "admin_access_denied", userId: payload.userId, endpoint: req.path, }, ); return res.status(403).json({ error: "Admin access required" }); } const authReq = req as AuthenticatedRequest; authReq.userId = payload.userId; authReq.pendingTOTP = payload.pendingTOTP; next(); } catch (error) { databaseLogger.error("Failed to verify admin privileges", error, { operation: "admin_check_failed", userId: payload.userId, }); return res .status(500) .json({ error: "Failed to verify admin privileges" }); } }; } async logoutUser(userId: string, sessionId?: string): Promise { if (sessionId) { try { await db.delete(sessions).where(eq(sessions.id, sessionId)); try { const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (saveError) { databaseLogger.error( "Failed to save database after logout", saveError, { operation: "logout_db_save_failed", userId, sessionId, }, ); } const remainingSessions = await db .select() .from(sessions) .where(eq(sessions.userId, userId)); if (remainingSessions.length === 0) { this.userCrypto.logoutUser(userId); } else { // expected - other sessions still active, keep user crypto state } } catch (error) { databaseLogger.error("Failed to delete session on logout", error, { operation: "session_delete_logout_failed", userId, sessionId, }); } } else { this.userCrypto.logoutUser(userId); } } getUserDataKey(userId: string): Buffer | null { return this.userCrypto.getUserDataKey(userId); } isUserUnlocked(userId: string): boolean { return this.userCrypto.isUserUnlocked(userId); } async changeUserPassword( userId: string, oldPassword: string, newPassword: string, ): Promise { return await this.userCrypto.changeUserPassword( userId, oldPassword, newPassword, ); } async resetUserPasswordWithPreservedDEK( userId: string, newPassword: string, ): Promise { return await this.userCrypto.resetUserPasswordWithPreservedDEK( userId, newPassword, ); } async isTrustedDevice( userId: string, deviceFingerprint: string, ): Promise { try { const device = await db .select() .from(trustedDevices) .where( and( eq(trustedDevices.userId, userId), eq(trustedDevices.deviceFingerprint, deviceFingerprint), ), ) .limit(1); if (!device || device.length === 0) { return false; } const now = new Date(); const expiresAt = new Date(device[0].expiresAt); if (now > expiresAt) { await this.removeTrustedDevice(userId, deviceFingerprint); return false; } await db .update(trustedDevices) .set({ lastUsedAt: now.toISOString() }) .where( and( eq(trustedDevices.userId, userId), eq(trustedDevices.deviceFingerprint, deviceFingerprint), ), ); return true; } catch (error) { authLogger.error("Failed to check trusted device", { userId, error }); return false; } } async addTrustedDevice( userId: string, deviceFingerprint: string, deviceType: string, deviceInfo: string, ): Promise { const now = new Date(); const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); const existingDevice = await db .select() .from(trustedDevices) .where( and( eq(trustedDevices.userId, userId), eq(trustedDevices.deviceFingerprint, deviceFingerprint), ), ) .limit(1); if (existingDevice && existingDevice.length > 0) { await db .update(trustedDevices) .set({ expiresAt: expiresAt.toISOString(), lastUsedAt: now.toISOString(), }) .where( and( eq(trustedDevices.userId, userId), eq(trustedDevices.deviceFingerprint, deviceFingerprint), ), ); } else { await db.insert(trustedDevices).values({ id: nanoid(), userId, deviceFingerprint, deviceType, deviceInfo, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString(), lastUsedAt: now.toISOString(), }); } } async removeTrustedDevice( userId: string, deviceFingerprint: string, ): Promise { await db .delete(trustedDevices) .where( and( eq(trustedDevices.userId, userId), eq(trustedDevices.deviceFingerprint, deviceFingerprint), ), ); } } export { AuthManager, type AuthenticationResult, type JWTPayload }; ================================================ FILE: src/backend/utils/auto-ssl-setup.ts ================================================ import { execSync } from "child_process"; import { promises as fs } from "fs"; import path from "path"; import { systemLogger } from "./logger.js"; export class AutoSSLSetup { private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data"; private static readonly SSL_DIR = path.join(AutoSSLSetup.DATA_DIR, "ssl"); private static readonly CERT_FILE = path.join( AutoSSLSetup.SSL_DIR, "termix.crt", ); private static readonly KEY_FILE = path.join( AutoSSLSetup.SSL_DIR, "termix.key", ); private static readonly ENV_FILE = path.join(AutoSSLSetup.DATA_DIR, ".env"); static async initialize(): Promise { if (process.env.ENABLE_SSL !== "true") { systemLogger.info("SSL not enabled - skipping certificate generation", { operation: "ssl_disabled_default", enable_ssl: process.env.ENABLE_SSL || "undefined", note: "Set ENABLE_SSL=true to enable SSL certificate generation", }); return; } try { if (await this.isSSLConfigured()) { await this.logCertificateInfo(); await this.setupEnvironmentVariables(); return; } try { await fs.access(this.CERT_FILE); await fs.access(this.KEY_FILE); systemLogger.info("SSL certificates found from entrypoint script", { operation: "ssl_cert_found_entrypoint", cert_path: this.CERT_FILE, key_path: this.KEY_FILE, }); await this.logCertificateInfo(); await this.setupEnvironmentVariables(); return; } catch { await this.generateSSLCertificates(); await this.setupEnvironmentVariables(); } } catch (error) { systemLogger.error("Failed to initialize SSL configuration", error, { operation: "ssl_auto_init_failed", }); systemLogger.warn("Falling back to HTTP-only mode", { operation: "ssl_fallback_http", }); } } private static async isSSLConfigured(): Promise { try { await fs.access(this.CERT_FILE); await fs.access(this.KEY_FILE); execSync( `openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, { stdio: "pipe", }, ); return true; } catch (error) { if (error instanceof Error && error.message.includes("checkend")) { systemLogger.warn( "SSL certificate is expired or expiring soon, will regenerate", { operation: "ssl_cert_expired", cert_path: this.CERT_FILE, error: error.message, }, ); } else { systemLogger.info( "SSL certificate not found or invalid, will generate new one", { operation: "ssl_cert_missing", cert_path: this.CERT_FILE, }, ); } return false; } } private static async generateSSLCertificates(): Promise { try { try { execSync("openssl version", { stdio: "pipe" }); } catch { throw new Error( "OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.", ); } await fs.mkdir(this.SSL_DIR, { recursive: true }); const configFile = path.join(this.SSL_DIR, "openssl.conf"); const opensslConfig = ` [req] default_bits = 2048 prompt = no default_md = sha256 distinguished_name = dn req_extensions = v3_req [dn] C=US ST=State L=City O=Termix OU=IT Department CN=localhost [v3_req] basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = localhost DNS.2 = 127.0.0.1 DNS.3 = *.localhost DNS.4 = termix.local DNS.5 = *.termix.local IP.1 = 127.0.0.1 IP.2 = ::1 IP.3 = 0.0.0.0 `.trim(); await fs.writeFile(configFile, opensslConfig); execSync(`openssl genrsa -out "${this.KEY_FILE}" 2048`, { stdio: "pipe", }); execSync( `openssl req -new -x509 -key "${this.KEY_FILE}" -out "${this.CERT_FILE}" -days 365 -config "${configFile}" -extensions v3_req`, { stdio: "pipe", }, ); await fs.chmod(this.KEY_FILE, 0o600); await fs.chmod(this.CERT_FILE, 0o644); await fs.unlink(configFile); systemLogger.success("SSL certificates generated successfully", { operation: "ssl_cert_generated", cert_path: this.CERT_FILE, key_path: this.KEY_FILE, valid_days: 365, }); await this.logCertificateInfo(); } catch (error) { throw new Error( `SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } private static async logCertificateInfo(): Promise { try { const subject = execSync( `openssl x509 -in "${this.CERT_FILE}" -noout -subject`, { stdio: "pipe" }, ) .toString() .trim(); const issuer = execSync( `openssl x509 -in "${this.CERT_FILE}" -noout -issuer`, { stdio: "pipe" }, ) .toString() .trim(); const notAfter = execSync( `openssl x509 -in "${this.CERT_FILE}" -noout -enddate`, { stdio: "pipe" }, ) .toString() .trim(); const notBefore = execSync( `openssl x509 -in "${this.CERT_FILE}" -noout -startdate`, { stdio: "pipe" }, ) .toString() .trim(); systemLogger.info("SSL Certificate Information:", { operation: "ssl_cert_info", subject: subject.replace("subject=", ""), issuer: issuer.replace("issuer=", ""), valid_from: notBefore.replace("notBefore=", ""), valid_until: notAfter.replace("notAfter=", ""), note: "Certificate will auto-renew 30 days before expiration", }); } catch (error) { systemLogger.warn("Could not retrieve certificate information", { operation: "ssl_cert_info_error", error: error instanceof Error ? error.message : "Unknown error", }); } } private static async setupEnvironmentVariables(): Promise { const certPath = this.CERT_FILE; const keyPath = this.KEY_FILE; const sslEnvVars = { ENABLE_SSL: "false", SSL_PORT: process.env.SSL_PORT || "8443", SSL_CERT_PATH: certPath, SSL_KEY_PATH: keyPath, SSL_DOMAIN: "localhost", }; let envContent = ""; try { envContent = await fs.readFile(this.ENV_FILE, "utf8"); } catch { // expected - env file may not exist yet } let updatedContent = envContent; let hasChanges = false; for (const [key, value] of Object.entries(sslEnvVars)) { const regex = new RegExp(`^${key}=.*$`, "m"); if (regex.test(updatedContent)) { updatedContent = updatedContent.replace(regex, `${key}=${value}`); } else { if (!updatedContent.includes(`# SSL Configuration`)) { updatedContent += `\n# SSL Configuration (Auto-generated)\n`; } updatedContent += `${key}=${value}\n`; hasChanges = true; } } if (hasChanges || !envContent) { await fs.writeFile(this.ENV_FILE, updatedContent.trim() + "\n"); systemLogger.info("SSL environment variables configured", { operation: "ssl_env_configured", file: this.ENV_FILE, variables: Object.keys(sslEnvVars), }); } for (const [key, value] of Object.entries(sslEnvVars)) { process.env[key] = value; } } static getSSLConfig() { return { enabled: process.env.ENABLE_SSL === "true", port: parseInt(process.env.SSL_PORT || "8443"), certPath: process.env.SSL_CERT_PATH || this.CERT_FILE, keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE, domain: process.env.SSL_DOMAIN || "localhost", }; } } ================================================ FILE: src/backend/utils/credential-system-encryption-migration.ts ================================================ import { db } from "../database/db/index.js"; import { sshCredentials } from "../database/db/schema.js"; import { eq, and, or, isNull } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; export class CredentialSystemEncryptionMigration { async migrateUserCredentials(userId: string): Promise<{ migrated: number; failed: number; skipped: number; }> { try { const userDEK = DataCrypto.getUserDataKey(userId); if (!userDEK) { throw new Error("User must be logged in to migrate credentials"); } const systemCrypto = SystemCrypto.getInstance(); const CSKEK = await systemCrypto.getCredentialSharingKey(); const credentials = await db .select() .from(sshCredentials) .where( and( eq(sshCredentials.userId, userId), or( isNull(sshCredentials.systemPassword), isNull(sshCredentials.systemKey), isNull(sshCredentials.systemKeyPassword), ), ), ); let migrated = 0; let failed = 0; const skipped = 0; for (const cred of credentials) { try { const plainPassword = cred.password ? FieldCrypto.decryptField( cred.password, userDEK, cred.id.toString(), "password", ) : null; const plainKey = cred.key ? FieldCrypto.decryptField( cred.key, userDEK, cred.id.toString(), "key", ) : null; const plainKeyPassword = cred.keyPassword ? FieldCrypto.decryptField( cred.keyPassword, userDEK, cred.id.toString(), "key_password", ) : null; const systemPassword = plainPassword ? FieldCrypto.encryptField( plainPassword, CSKEK, cred.id.toString(), "password", ) : null; const systemKey = plainKey ? FieldCrypto.encryptField( plainKey, CSKEK, cred.id.toString(), "key", ) : null; const systemKeyPassword = plainKeyPassword ? FieldCrypto.encryptField( plainKeyPassword, CSKEK, cred.id.toString(), "key_password", ) : null; await db .update(sshCredentials) .set({ systemPassword, systemKey, systemKeyPassword, updatedAt: new Date().toISOString(), }) .where(eq(sshCredentials.id, cred.id)); migrated++; } catch (error) { databaseLogger.error("Failed to migrate credential", error, { credentialId: cred.id, userId, }); failed++; } } return { migrated, failed, skipped }; } catch (error) { databaseLogger.error( "Credential system encryption migration failed", error, { operation: "credential_migration_failed", userId, error: error instanceof Error ? error.message : "Unknown error", }, ); throw error; } } } ================================================ FILE: src/backend/utils/data-crypto.ts ================================================ import { FieldCrypto } from "./field-crypto.js"; import { LazyFieldEncryption } from "./lazy-field-encryption.js"; import { UserCrypto } from "./user-crypto.js"; import { databaseLogger } from "./logger.js"; interface DatabaseInstance { prepare: (sql: string) => { all: (param?: unknown) => DatabaseRecord[]; get: (param?: unknown) => DatabaseRecord; run: (...params: unknown[]) => unknown; }; } interface DatabaseRecord { id: number | string; [key: string]: unknown; } class DataCrypto { private static userCrypto: UserCrypto; static initialize() { this.userCrypto = UserCrypto.getInstance(); } static encryptRecord>( tableName: string, record: T, userId: string, userDataKey: Buffer, ): T { const encryptedRecord: Record = { ...record }; const recordId = record.id || "temp-" + Date.now(); for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { encryptedRecord[fieldName] = FieldCrypto.encryptField( value as string, userDataKey, recordId as string, fieldName, ); } } return encryptedRecord as T; } static decryptRecord>( tableName: string, record: T, userId: string, userDataKey: Buffer, ): T { if (!record) return record; const decryptedRecord: Record = { ...record }; const recordId = record.id; for (const [fieldName, value] of Object.entries(record)) { if (FieldCrypto.shouldEncryptField(tableName, fieldName) && value) { decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue( value as string, userDataKey, recordId as string, fieldName, ); } } return decryptedRecord as T; } static decryptRecords>( tableName: string, records: T[], userId: string, userDataKey: Buffer, ): T[] { if (!Array.isArray(records)) return records; return records.map((record) => this.decryptRecord(tableName, record, userId, userDataKey), ); } static async migrateUserSensitiveFields( userId: string, userDataKey: Buffer, db: DatabaseInstance, ): Promise<{ migrated: boolean; migratedTables: string[]; migratedFieldsCount: number; }> { let migrated = false; const migratedTables: string[] = []; let migratedFieldsCount = 0; try { const { needsMigration } = await LazyFieldEncryption.checkUserNeedsMigration( userId, userDataKey, db, ); if (!needsMigration) { return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; } const sshDataRecords = db .prepare("SELECT * FROM ssh_data WHERE user_id = ?") .all(userId) as DatabaseRecord[]; for (const record of sshDataRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data"); const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields( record, sensitiveFields, userDataKey, record.id.toString(), ); if (needsUpdate) { const updateQuery = ` UPDATE ssh_data SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, sudo_password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, updatedRecord.key_password || null, updatedRecord.key_type || null, updatedRecord.autostart_password || null, updatedRecord.autostart_key || null, updatedRecord.autostart_key_password || null, updatedRecord.sudo_password || null, record.id, ); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_data")) { migratedTables.push("ssh_data"); } migrated = true; } } const sshCredentialsRecords = db .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") .all(userId) as DatabaseRecord[]; for (const record of sshCredentialsRecords) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials"); const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields( record, sensitiveFields, userDataKey, record.id.toString(), ); if (needsUpdate) { const updateQuery = ` UPDATE ssh_credentials SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `; db.prepare(updateQuery).run( updatedRecord.password || null, updatedRecord.key || null, updatedRecord.key_password || null, updatedRecord.private_key || null, updatedRecord.public_key || null, updatedRecord.key_type || null, record.id, ); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("ssh_credentials")) { migratedTables.push("ssh_credentials"); } migrated = true; } } const userRecord = db .prepare("SELECT * FROM users WHERE id = ?") .get(userId) as DatabaseRecord | undefined; if (userRecord) { const sensitiveFields = LazyFieldEncryption.getSensitiveFieldsForTable("users"); const { updatedRecord, migratedFields, needsUpdate } = LazyFieldEncryption.migrateRecordSensitiveFields( userRecord, sensitiveFields, userDataKey, userId, ); if (needsUpdate) { const updateQuery = ` UPDATE users SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ? WHERE id = ? `; db.prepare(updateQuery).run( updatedRecord.totp_secret || null, updatedRecord.totp_backup_codes || null, updatedRecord.client_secret || null, updatedRecord.oidc_identifier || null, userId, ); migratedFieldsCount += migratedFields.length; if (!migratedTables.includes("users")) { migratedTables.push("users"); } migrated = true; } } return { migrated, migratedTables, migratedFieldsCount }; } catch (error) { databaseLogger.error("User sensitive fields migration failed", error, { operation: "user_sensitive_migration_failed", userId, error: error instanceof Error ? error.message : "Unknown error", }); return { migrated: false, migratedTables: [], migratedFieldsCount: 0 }; } } static getUserDataKey(userId: string): Buffer | null { return this.userCrypto.getUserDataKey(userId); } static async reencryptUserDataAfterPasswordReset( userId: string, newUserDataKey: Buffer, db: DatabaseInstance, ): Promise<{ success: boolean; reencryptedTables: string[]; reencryptedFieldsCount: number; errors: string[]; }> { const result = { success: false, reencryptedTables: [] as string[], reencryptedFieldsCount: 0, errors: [] as string[], }; try { const tablesToReencrypt = [ { table: "ssh_data", fields: [ "password", "key", "key_password", "sudo_password", "autostart_password", "autostart_key", "autostart_key_password", ], }, { table: "ssh_credentials", fields: [ "password", "key", "private_key", "public_key", "key_password", ], }, { table: "users", fields: [ "client_secret", "totp_secret", "totp_backup_codes", "oidc_identifier", ], }, ]; for (const { table, fields } of tablesToReencrypt) { try { const selectQuery = table === "users" ? `SELECT * FROM ${table} WHERE id = ?` : `SELECT * FROM ${table} WHERE user_id = ?`; const records = db .prepare(selectQuery) .all(userId) as DatabaseRecord[]; for (const record of records) { const recordId = record.id.toString(); const updatedRecord: DatabaseRecord = { ...record }; let needsUpdate = false; for (const fieldName of fields) { const fieldValue = record[fieldName]; if ( fieldValue && typeof fieldValue === "string" && fieldValue.trim() !== "" ) { try { const reencryptedValue = FieldCrypto.encryptField( fieldValue, newUserDataKey, recordId, fieldName, ); updatedRecord[fieldName] = reencryptedValue; needsUpdate = true; result.reencryptedFieldsCount++; } catch (error) { const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`; result.errors.push(errorMsg); databaseLogger.warn( "Field re-encryption failed during password reset", { operation: "password_reset_reencrypt_failed", userId, table, recordId, fieldName, error: error instanceof Error ? error.message : "Unknown error", }, ); } } } if (needsUpdate) { const updateFields = fields.filter( (field) => updatedRecord[field] !== record[field], ); if (updateFields.length > 0) { const setClause = updateFields .map((f) => `${f} = ?`) .join(", "); const updateQuery = table === "users" ? `UPDATE ${table} SET ${setClause} WHERE id = ?` : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`; const updateValues = updateFields.map( (field) => updatedRecord[field], ); updateValues.push(record.id); db.prepare(updateQuery).run(...updateValues); if (!result.reencryptedTables.includes(table)) { result.reencryptedTables.push(table); } } } } } catch (tableError) { const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`; result.errors.push(errorMsg); databaseLogger.error( "Table re-encryption failed during password reset", tableError, { operation: "password_reset_table_reencrypt_failed", userId, table, error: tableError instanceof Error ? tableError.message : "Unknown error", }, ); } } result.success = result.errors.length === 0; return result; } catch (error) { databaseLogger.error( "User data re-encryption failed after password reset", error, { operation: "password_reset_reencrypt_failed", userId, error: error instanceof Error ? error.message : "Unknown error", }, ); result.errors.push( `Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`, ); return result; } } static validateUserAccess(userId: string): Buffer { const userDataKey = this.getUserDataKey(userId); if (!userDataKey) { throw new Error(`User ${userId} data not unlocked`); } return userDataKey; } static encryptRecordForUser>( tableName: string, record: T, userId: string, ): T { const userDataKey = this.validateUserAccess(userId); return this.encryptRecord(tableName, record, userId, userDataKey); } static decryptRecordForUser>( tableName: string, record: T, userId: string, ): T { const userDataKey = this.validateUserAccess(userId); return this.decryptRecord(tableName, record, userId, userDataKey); } static decryptRecordsForUser>( tableName: string, records: T[], userId: string, ): T[] { const userDataKey = this.validateUserAccess(userId); return this.decryptRecords(tableName, records, userId, userDataKey); } static canUserAccessData(userId: string): boolean { return this.userCrypto.isUserUnlocked(userId); } static testUserEncryption(userId: string): boolean { try { const userDataKey = this.getUserDataKey(userId); if (!userDataKey) return false; const testData = "test-" + Date.now(); const encrypted = FieldCrypto.encryptField( testData, userDataKey, "test-record", "test-field", ); const decrypted = FieldCrypto.decryptField( encrypted, userDataKey, "test-record", "test-field", ); return decrypted === testData; } catch { return false; } } static async encryptRecordWithSystemKey>( tableName: string, record: T, systemKey: Buffer, ): Promise> { const systemEncrypted: Record = {}; const recordId = record.id || "temp-" + Date.now(); if (tableName !== "ssh_credentials") { return systemEncrypted as Partial; } if (record.password && typeof record.password === "string") { systemEncrypted.systemPassword = FieldCrypto.encryptField( record.password as string, systemKey, recordId as string, "password", ); } if (record.key && typeof record.key === "string") { systemEncrypted.systemKey = FieldCrypto.encryptField( record.key as string, systemKey, recordId as string, "key", ); } if (record.keyPassword && typeof record.keyPassword === "string") { systemEncrypted.systemKeyPassword = FieldCrypto.encryptField( record.keyPassword as string, systemKey, recordId as string, "key_password", ); } return systemEncrypted as Partial; } } export { DataCrypto }; ================================================ FILE: src/backend/utils/database-file-encryption.ts ================================================ import crypto from "crypto"; import fs from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; import { SystemCrypto } from "./system-crypto.js"; interface EncryptedFileMetadata { iv: string; tag: string; version: string; fingerprint: string; algorithm: string; keySource?: string; salt?: string; dataSize?: number; } class DatabaseFileEncryption { private static readonly VERSION = "v2"; private static readonly ALGORITHM = "aes-256-gcm"; private static readonly ENCRYPTED_FILE_SUFFIX = ".encrypted"; private static readonly METADATA_FILE_SUFFIX = ".meta"; private static systemCrypto = SystemCrypto.getInstance(); static async encryptDatabaseFromBuffer( buffer: Buffer, targetPath: string, ): Promise { const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { const key = await this.systemCrypto.getDatabaseKey(); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv( this.ALGORITHM, key, iv, ) as crypto.CipherGCM; const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), version: this.VERSION, fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", dataSize: encrypted.length, }; const metadataJson = JSON.stringify(metadata, null, 2); const metadataBuffer = Buffer.from(metadataJson, "utf8"); const metadataLengthBuffer = Buffer.alloc(4); metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0); const finalBuffer = Buffer.concat([ metadataLengthBuffer, metadataBuffer, encrypted, ]); fs.writeFileSync(tmpPath, finalBuffer); fs.renameSync(tmpPath, targetPath); try { if (fs.existsSync(metadataPath)) { fs.unlinkSync(metadataPath); } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup old metadata file", { operation: "old_meta_cleanup_failed", path: metadataPath, error: cleanupError instanceof Error ? cleanupError.message : "Unknown error", }); } return targetPath; } catch (error) { try { if (fs.existsSync(tmpPath)) { fs.unlinkSync(tmpPath); } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup temporary files", { operation: "temp_file_cleanup_failed", tmpPath, error: cleanupError instanceof Error ? cleanupError.message : "Unknown error", }); } databaseLogger.error("Failed to encrypt database buffer", error, { operation: "database_buffer_encryption_failed", targetPath, }); throw new Error( `Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } static async encryptDatabaseFile( sourcePath: string, targetPath?: string, ): Promise { if (!fs.existsSync(sourcePath)) { throw new Error(`Source database file does not exist: ${sourcePath}`); } const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { const sourceData = fs.readFileSync(sourcePath); const key = await this.systemCrypto.getDatabaseKey(); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv( this.ALGORITHM, key, iv, ) as crypto.CipherGCM; const encrypted = Buffer.concat([ cipher.update(sourceData), cipher.final(), ]); const tag = cipher.getAuthTag(); const keyFingerprint = crypto .createHash("sha256") .update(key) .digest("hex") .substring(0, 16); const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), version: this.VERSION, fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", dataSize: encrypted.length, }; fs.writeFileSync(tmpPath, encrypted); fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); fs.renameSync(tmpPath, encryptedPath); fs.renameSync(tmpMetadataPath, metadataPath); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption", sourcePath, encryptedPath, fileSize: sourceData.length, encryptedSize: encrypted.length, keyFingerprint, fingerprintPrefix: metadata.fingerprint, }); return encryptedPath; } catch (error) { try { if (fs.existsSync(tmpPath)) { fs.unlinkSync(tmpPath); } if (fs.existsSync(tmpMetadataPath)) { fs.unlinkSync(tmpMetadataPath); } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup temporary files", { operation: "temp_file_cleanup_failed", tmpPath, error: cleanupError instanceof Error ? cleanupError.message : "Unknown error", }); } databaseLogger.error("Failed to encrypt database file", error, { operation: "database_file_encryption_failed", sourcePath, targetPath: encryptedPath, }); throw new Error( `Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } static async decryptDatabaseToBuffer(encryptedPath: string): Promise { if (!fs.existsSync(encryptedPath)) { throw new Error( `Encrypted database file does not exist: ${encryptedPath}`, ); } let metadata: EncryptedFileMetadata; let encryptedData: Buffer; const fileBuffer = fs.readFileSync(encryptedPath); try { const metadataLength = fileBuffer.readUInt32BE(0); const metadataEnd = 4 + metadataLength; if ( metadataLength <= 0 || metadataEnd > fileBuffer.length || metadataEnd <= 4 ) { throw new Error("Invalid metadata length in single-file format"); } const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); metadata = JSON.parse(metadataJson); encryptedData = fileBuffer.slice(metadataEnd); if (!metadata.iv || !metadata.tag || !metadata.version) { throw new Error("Invalid metadata structure in single-file format"); } } catch (singleFileError) { const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; if (!fs.existsSync(metadataPath)) { throw new Error( `Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`, ); } try { const metadataContent = fs.readFileSync(metadataPath, "utf8"); metadata = JSON.parse(metadataContent); encryptedData = fileBuffer; } catch (twoFileError) { throw new Error( `Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`, ); } } try { if ( metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, { operation: "database_file_size_mismatch", encryptedPath, actualSize: encryptedData.length, expectedSize: metadata.dataSize, }, ); throw new Error( `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + `This indicates corrupted files or interrupted write operation.`, ); } let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); } else if (metadata.version === "v1") { databaseLogger.warn( "Decrypting legacy v1 encrypted database - consider upgrading", { operation: "decrypt_legacy_v1", path: encryptedPath, }, ); if (!metadata.salt) { throw new Error("v1 encrypted file missing required salt field"); } const salt = Buffer.from(metadata.salt, "hex"); const fixedSeed = process.env.DB_FILE_KEY || "termix-database-file-encryption-seed-v1"; key = crypto.pbkdf2Sync(fixedSeed, salt, 100000, 32, "sha256"); } else { throw new Error(`Unsupported encryption version: ${metadata.version}`); } const decipher = crypto.createDecipheriv( metadata.algorithm, key, Buffer.from(metadata.iv, "hex"), ) as crypto.DecipherGCM; decipher.setAuthTag(Buffer.from(metadata.tag, "hex")); const decryptedBuffer = Buffer.concat([ decipher.update(encryptedData), decipher.final(), ]); return decryptedBuffer; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; const isAuthError = errorMessage.includes("Unsupported state") || errorMessage.includes("authenticate data") || errorMessage.includes("auth"); if (isAuthError) { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); let envFileExists = false; let envFileReadable = false; try { envFileExists = fs.existsSync(envPath); if (envFileExists) { fs.accessSync(envPath, fs.constants.R_OK); envFileReadable = true; } } catch { // expected - env file access check may fail } databaseLogger.error( "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", error, { operation: "database_buffer_decryption_auth_failed", encryptedPath, dataDir, envPath, envFileExists, envFileReadable, hasEnvKey: !!process.env.DATABASE_KEY, envKeyLength: process.env.DATABASE_KEY?.length || 0, suggestion: "Check if DATABASE_KEY in .env matches the key used for encryption", }, ); throw new Error( `Database decryption authentication failed. This usually means:\n` + `1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` + `2. Encrypted file was corrupted during write (system crash/restart)\n` + `3. Metadata file does not match encrypted data\n` + `\nDebug info:\n` + `- DATA_DIR: ${dataDir}\n` + `- .env file exists: ${envFileExists}\n` + `- .env file readable: ${envFileReadable}\n` + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + `Original error: ${errorMessage}`, ); } databaseLogger.error("Failed to decrypt database to buffer", error, { operation: "database_buffer_decryption_failed", encryptedPath, errorMessage, }); throw new Error(`Database buffer decryption failed: ${errorMessage}`); } } static async decryptDatabaseFile( encryptedPath: string, targetPath?: string, ): Promise { const decryptedPath = targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, ""); try { const decryptedBuffer = await this.decryptDatabaseToBuffer(encryptedPath); fs.writeFileSync(decryptedPath, decryptedBuffer); databaseLogger.info("Database file decrypted successfully", { operation: "database_file_decryption", encryptedPath, decryptedPath, decryptedSize: decryptedBuffer.length, }); return decryptedPath; } catch (error) { databaseLogger.error("Failed to decrypt database file", error, { operation: "database_file_decryption_failed", encryptedPath, targetPath: decryptedPath, }); throw new Error( `Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } static isEncryptedDatabaseFile(filePath: string): boolean { if (!fs.existsSync(filePath)) { return false; } const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; if (fs.existsSync(metadataPath)) { try { const metadataContent = fs.readFileSync(metadataPath, "utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); if ( metadata.version === this.VERSION && metadata.algorithm === this.ALGORITHM ) { return true; } } catch { // .meta parse failed, fall through to single-file detection } } try { const fileBuffer = fs.readFileSync(filePath); if (fileBuffer.length < 4) return false; const metadataLength = fileBuffer.readUInt32BE(0); const metadataEnd = 4 + metadataLength; if (metadataLength <= 0 || metadataEnd > fileBuffer.length) { return false; } const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataJson); return ( metadata.version === this.VERSION && metadata.algorithm === this.ALGORITHM && !!metadata.iv && !!metadata.tag ); } catch { return false; } } static getEncryptedFileInfo(encryptedPath: string): { version: string; algorithm: string; fingerprint: string; isCurrentHardware: boolean; fileSize: number; } | null { if (!this.isEncryptedDatabaseFile(encryptedPath)) { return null; } try { const fileStats = fs.statSync(encryptedPath); let metadata: EncryptedFileMetadata | null = null; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; if (fs.existsSync(metadataPath)) { try { const metadataContent = fs.readFileSync(metadataPath, "utf8"); metadata = JSON.parse(metadataContent); } catch { // .meta parse failed, try single-file format } } if (!metadata) { const fileBuffer = fs.readFileSync(encryptedPath); const metadataLength = fileBuffer.readUInt32BE(0); const metadataEnd = 4 + metadataLength; const metadataJson = fileBuffer .subarray(4, metadataEnd) .toString("utf8"); metadata = JSON.parse(metadataJson); } if (!metadata) { return null; } return { version: metadata.version, algorithm: metadata.algorithm, fingerprint: metadata.fingerprint, isCurrentHardware: true, fileSize: fileStats.size, }; } catch { return null; } } static getDiagnosticInfo(encryptedPath: string): { dataFile: { exists: boolean; size?: number; mtime?: string; readable?: boolean; }; metadataFile: { exists: boolean; size?: number; mtime?: string; readable?: boolean; content?: EncryptedFileMetadata; }; environment: { dataDir: string; envPath: string; envFileExists: boolean; envFileReadable: boolean; hasEnvKey: boolean; envKeyLength: number; }; validation: { filesConsistent: boolean; sizeMismatch?: boolean; expectedSize?: number; actualSize?: number; }; } { const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); const result: ReturnType = { dataFile: { exists: false }, metadataFile: { exists: false }, environment: { dataDir, envPath, envFileExists: false, envFileReadable: false, hasEnvKey: !!process.env.DATABASE_KEY, envKeyLength: process.env.DATABASE_KEY?.length || 0, }, validation: { filesConsistent: false, }, }; try { result.dataFile.exists = fs.existsSync(encryptedPath); if (result.dataFile.exists) { try { fs.accessSync(encryptedPath, fs.constants.R_OK); result.dataFile.readable = true; const stats = fs.statSync(encryptedPath); result.dataFile.size = stats.size; result.dataFile.mtime = stats.mtime.toISOString(); } catch { result.dataFile.readable = false; } } result.metadataFile.exists = fs.existsSync(metadataPath); if (result.metadataFile.exists) { try { fs.accessSync(metadataPath, fs.constants.R_OK); result.metadataFile.readable = true; const stats = fs.statSync(metadataPath); result.metadataFile.size = stats.size; result.metadataFile.mtime = stats.mtime.toISOString(); const content = fs.readFileSync(metadataPath, "utf8"); result.metadataFile.content = JSON.parse(content); } catch { result.metadataFile.readable = false; } } result.environment.envFileExists = fs.existsSync(envPath); if (result.environment.envFileExists) { try { fs.accessSync(envPath, fs.constants.R_OK); result.environment.envFileReadable = true; } catch { // expected - env file access check may fail } } if ( result.dataFile.exists && result.metadataFile.exists && result.metadataFile.content ) { result.validation.filesConsistent = true; if (result.metadataFile.content.dataSize !== undefined) { result.validation.expectedSize = result.metadataFile.content.dataSize; result.validation.actualSize = result.dataFile.size; result.validation.sizeMismatch = result.metadataFile.content.dataSize !== result.dataFile.size; if (result.validation.sizeMismatch) { result.validation.filesConsistent = false; } } } } catch (error) { databaseLogger.error("Failed to generate diagnostic info", error, { operation: "diagnostic_info_failed", encryptedPath, }); } databaseLogger.info("Database encryption diagnostic info", { operation: "diagnostic_info_generated", ...result, }); return result; } static async createEncryptedBackup( databasePath: string, backupDir: string, ): Promise { if (!fs.existsSync(databasePath)) { throw new Error(`Database file does not exist: ${databasePath}`); } if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupFileName = `database-backup-${timestamp}.sqlite.encrypted`; const backupPath = path.join(backupDir, backupFileName); try { const encryptedPath = await this.encryptDatabaseFile( databasePath, backupPath, ); return encryptedPath; } catch (error) { databaseLogger.error("Failed to create encrypted backup", error, { operation: "database_backup_failed", sourcePath: databasePath, backupDir, }); throw error; } } static async restoreFromEncryptedBackup( backupPath: string, targetPath: string, ): Promise { if (!this.isEncryptedDatabaseFile(backupPath)) { throw new Error("Invalid encrypted backup file"); } try { const restoredPath = await this.decryptDatabaseFile( backupPath, targetPath, ); return restoredPath; } catch (error) { databaseLogger.error("Failed to restore from encrypted backup", error, { operation: "database_restore_failed", backupPath, targetPath, }); throw error; } } static cleanupTempFiles(basePath: string): void { try { const tempFiles = [ `${basePath}.tmp`, `${basePath}${this.ENCRYPTED_FILE_SUFFIX}`, `${basePath}${this.ENCRYPTED_FILE_SUFFIX}${this.METADATA_FILE_SUFFIX}`, ]; for (const tempFile of tempFiles) { if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } } } catch (error) { databaseLogger.warn("Failed to clean up temporary files", { operation: "temp_cleanup_failed", basePath, error: error instanceof Error ? error.message : "Unknown error", }); } } } export { DatabaseFileEncryption }; export type { EncryptedFileMetadata }; ================================================ FILE: src/backend/utils/database-migration.ts ================================================ import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; import { DatabaseFileEncryption } from "./database-file-encryption.js"; export interface MigrationResult { success: boolean; error?: string; migratedTables: number; migratedRows: number; backupPath?: string; duration: number; } export interface MigrationStatus { needsMigration: boolean; hasUnencryptedDb: boolean; hasEncryptedDb: boolean; unencryptedDbSize: number; reason: string; } export class DatabaseMigration { private dataDir: string; private unencryptedDbPath: string; private encryptedDbPath: string; constructor(dataDir: string) { this.dataDir = dataDir; this.unencryptedDbPath = path.join(dataDir, "db.sqlite"); this.encryptedDbPath = `${this.unencryptedDbPath}.encrypted`; } checkMigrationStatus(): MigrationStatus { const hasUnencryptedDb = fs.existsSync(this.unencryptedDbPath); const hasEncryptedDb = DatabaseFileEncryption.isEncryptedDatabaseFile( this.encryptedDbPath, ); let unencryptedDbSize = 0; if (hasUnencryptedDb) { try { unencryptedDbSize = fs.statSync(this.unencryptedDbPath).size; } catch (error) { databaseLogger.warn("Could not get unencrypted database file size", { operation: "migration_status_check", error: error instanceof Error ? error.message : "Unknown error", }); } } let needsMigration = false; let reason = ""; if (hasEncryptedDb && hasUnencryptedDb) { const unencryptedSize = fs.statSync(this.unencryptedDbPath).size; if (unencryptedSize === 0) { needsMigration = false; reason = "Empty unencrypted database found alongside encrypted database. Removing empty file."; try { fs.unlinkSync(this.unencryptedDbPath); } catch (error) { databaseLogger.warn("Failed to remove empty unencrypted database", { operation: "migration_cleanup_empty_failed", error: error instanceof Error ? error.message : "Unknown error", }); } } else { needsMigration = false; reason = "Both encrypted and unencrypted databases exist. Skipping migration for safety. Manual intervention may be required."; } } else if (hasEncryptedDb && !hasUnencryptedDb) { needsMigration = false; reason = "Only encrypted database exists. No migration needed."; } else if (!hasEncryptedDb && hasUnencryptedDb) { needsMigration = true; reason = "Unencrypted database found. Migration to encrypted format required."; } else { needsMigration = false; reason = "No existing database found. This is a fresh installation."; } return { needsMigration, hasUnencryptedDb, hasEncryptedDb, unencryptedDbSize, reason, }; } private createBackup(): string { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupPath = `${this.unencryptedDbPath}.migration-backup-${timestamp}`; try { fs.copyFileSync(this.unencryptedDbPath, backupPath); const originalSize = fs.statSync(this.unencryptedDbPath).size; const backupSize = fs.statSync(backupPath).size; if (originalSize !== backupSize) { throw new Error( `Backup size mismatch: original=${originalSize}, backup=${backupSize}`, ); } return backupPath; } catch (error) { databaseLogger.error("Failed to create migration backup", error, { operation: "migration_backup_failed", source: this.unencryptedDbPath, backup: backupPath, }); throw new Error( `Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } } private async verifyMigration( originalDb: Database.Database, memoryDb: Database.Database, ): Promise { try { memoryDb.exec("PRAGMA foreign_keys = OFF"); const originalTables = originalDb .prepare( ` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name `, ) .all() as { name: string }[]; const memoryTables = memoryDb .prepare( ` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name `, ) .all() as { name: string }[]; if (originalTables.length !== memoryTables.length) { databaseLogger.error( "Table count mismatch during migration verification", null, { operation: "migration_verify_failed", originalCount: originalTables.length, memoryCount: memoryTables.length, }, ); return false; } for (const table of originalTables) { const originalCount = originalDb .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) .get() as { count: number }; const memoryCount = memoryDb .prepare(`SELECT COUNT(*) as count FROM ${table.name}`) .get() as { count: number }; if (originalCount.count !== memoryCount.count) { databaseLogger.error( "Row count mismatch for table during migration verification", null, { operation: "migration_verify_table_failed", table: table.name, originalRows: originalCount.count, memoryRows: memoryCount.count, }, ); return false; } } memoryDb.exec("PRAGMA foreign_keys = ON"); return true; } catch (error) { databaseLogger.error("Migration verification failed", error, { operation: "migration_verify_error", }); return false; } } async migrateDatabase(): Promise { const startTime = Date.now(); let backupPath: string | undefined; let migratedTables = 0; let migratedRows = 0; try { backupPath = this.createBackup(); const originalDb = new Database(this.unencryptedDbPath, { readonly: true, }); const memoryDb = new Database(":memory:"); try { const tables = originalDb .prepare( ` SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' `, ) .all() as { name: string; sql: string }[]; for (const table of tables) { memoryDb.exec(table.sql); migratedTables++; } memoryDb.exec("PRAGMA foreign_keys = OFF"); for (const table of tables) { const rows = originalDb .prepare(`SELECT * FROM ${table.name}`) .all() as Record[]; if (rows.length > 0) { const columns = Object.keys(rows[0]); const placeholders = columns.map(() => "?").join(", "); const insertStmt = memoryDb.prepare( `INSERT INTO ${table.name} (${columns.join(", ")}) VALUES (${placeholders})`, ); const insertTransaction = memoryDb.transaction( (dataRows: Record[]) => { for (const row of dataRows) { const values = columns.map((col) => row[col]); insertStmt.run(values); } }, ); insertTransaction(rows); migratedRows += rows.length; } } memoryDb.exec("PRAGMA foreign_keys = ON"); const fkCheckResult = memoryDb .prepare("PRAGMA foreign_key_check") .all(); if (fkCheckResult.length > 0) { databaseLogger.error( "Foreign key constraints violations detected after migration", null, { operation: "migration_fk_check_failed", violations: fkCheckResult, }, ); throw new Error( `Foreign key violations detected: ${JSON.stringify(fkCheckResult)}`, ); } const verificationPassed = await this.verifyMigration( originalDb, memoryDb, ); if (!verificationPassed) { throw new Error("Migration integrity verification failed"); } const buffer = memoryDb.serialize(); await DatabaseFileEncryption.encryptDatabaseFromBuffer( buffer, this.encryptedDbPath, ); if ( !DatabaseFileEncryption.isEncryptedDatabaseFile(this.encryptedDbPath) ) { throw new Error("Encrypted database file verification failed"); } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const migratedPath = `${this.unencryptedDbPath}.migrated-${timestamp}`; fs.renameSync(this.unencryptedDbPath, migratedPath); databaseLogger.success("Database migration completed successfully", { operation: "migration_complete", migratedTables, migratedRows, duration: Date.now() - startTime, backupPath, migratedPath, encryptedDbPath: this.encryptedDbPath, }); return { success: true, migratedTables, migratedRows, backupPath, duration: Date.now() - startTime, }; } finally { originalDb.close(); memoryDb.close(); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; databaseLogger.error("Database migration failed", error, { operation: "migration_failed", migratedTables, migratedRows, duration: Date.now() - startTime, backupPath, }); return { success: false, error: errorMessage, migratedTables, migratedRows, backupPath, duration: Date.now() - startTime, }; } } cleanupOldBackups(): void { try { const backupPattern = /\.migration-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/; const migratedPattern = /\.migrated-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/; const files = fs.readdirSync(this.dataDir); const backupFiles = files .filter((f) => backupPattern.test(f)) .map((f) => ({ name: f, path: path.join(this.dataDir, f), mtime: fs.statSync(path.join(this.dataDir, f)).mtime, })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); const migratedFiles = files .filter((f) => migratedPattern.test(f)) .map((f) => ({ name: f, path: path.join(this.dataDir, f), mtime: fs.statSync(path.join(this.dataDir, f)).mtime, })) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); const backupsToDelete = backupFiles.slice(3); const migratedToDelete = migratedFiles.slice(3); for (const file of [...backupsToDelete, ...migratedToDelete]) { try { fs.unlinkSync(file.path); } catch (error) { databaseLogger.warn("Failed to cleanup old migration file", { operation: "migration_cleanup_failed", file: file.name, error: error instanceof Error ? error.message : "Unknown error", }); } } } catch (error) { databaseLogger.warn("Migration cleanup failed", { operation: "migration_cleanup_error", error: error instanceof Error ? error.message : "Unknown error", }); } } } ================================================ FILE: src/backend/utils/database-save-trigger.ts ================================================ import { databaseLogger } from "./logger.js"; export class DatabaseSaveTrigger { private static saveFunction: (() => Promise) | null = null; private static isInitialized = false; private static pendingSave = false; private static saveTimeout: NodeJS.Timeout | null = null; private static _dirty = false; static initialize(saveFunction: () => Promise): void { this.saveFunction = saveFunction; this.isInitialized = true; } static get isDirty(): boolean { return this._dirty; } static markClean(): void { this._dirty = false; } static async triggerSave( reason: string = "data_modification", ): Promise { if (!this.isInitialized || !this.saveFunction) { databaseLogger.warn("Database save trigger not initialized", { operation: "db_save_trigger_not_init", reason, }); return; } this._dirty = true; if (this.saveTimeout) { clearTimeout(this.saveTimeout); } this.saveTimeout = setTimeout(async () => { if (this.pendingSave) { return; } this.pendingSave = true; try { await this.saveFunction!(); this._dirty = false; } catch (error) { databaseLogger.error("Database save failed", error, { operation: "db_save_trigger_failed", reason, error: error instanceof Error ? error.message : "Unknown error", }); } finally { this.pendingSave = false; } }, 2000); } static async forceSave(reason: string = "critical_operation"): Promise { if (!this.isInitialized || !this.saveFunction) { databaseLogger.warn( "Database save trigger not initialized for force save", { operation: "db_save_trigger_force_not_init", reason, }, ); return; } if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; } if (this.pendingSave) { return; } this.pendingSave = true; try { await this.saveFunction(); } catch (error) { databaseLogger.error("Database force save failed", error, { operation: "db_save_trigger_force_failed", reason, error: error instanceof Error ? error.message : "Unknown error", }); throw error; } finally { this.pendingSave = false; } } static getStatus(): { initialized: boolean; pendingSave: boolean; hasPendingTimeout: boolean; } { return { initialized: this.isInitialized, pendingSave: this.pendingSave, hasPendingTimeout: this.saveTimeout !== null, }; } static cleanup(): void { if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; } this.pendingSave = false; this.isInitialized = false; this.saveFunction = null; } } ================================================ FILE: src/backend/utils/field-crypto.ts ================================================ import crypto from "crypto"; interface EncryptedData { data: string; iv: string; tag: string; salt: string; recordId: string; } class FieldCrypto { private static readonly ALGORITHM = "aes-256-gcm"; private static readonly KEY_LENGTH = 32; private static readonly IV_LENGTH = 16; private static readonly SALT_LENGTH = 32; private static readonly ENCRYPTED_FIELDS = { users: new Set([ "passwordHash", "clientSecret", "totpSecret", "totpBackupCodes", "oidcIdentifier", ]), ssh_data: new Set([ "password", "key", "keyPassword", "sudoPassword", "autostartPassword", "autostartKey", "autostartKeyPassword", ]), ssh_credentials: new Set([ "password", "privateKey", "keyPassword", "key", "publicKey", ]), opkssh_tokens: new Set(["sshCert", "privateKey"]), }; static encryptField( plaintext: string, masterKey: Buffer, recordId: string, fieldName: string, ): string { if (!plaintext) return ""; const salt = crypto.randomBytes(this.SALT_LENGTH); const context = `${recordId}:${fieldName}`; const fieldKey = Buffer.from( crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH), ); const iv = crypto.randomBytes(this.IV_LENGTH); const cipher = crypto.createCipheriv( this.ALGORITHM, fieldKey, iv, ) as crypto.CipherGCM; let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const tag = cipher.getAuthTag(); const encryptedData: EncryptedData = { data: encrypted, iv: iv.toString("hex"), tag: tag.toString("hex"), salt: salt.toString("hex"), recordId: recordId, }; return JSON.stringify(encryptedData); } static decryptField( encryptedValue: string, masterKey: Buffer, recordId: string, fieldName: string, ): string { if (!encryptedValue) return ""; const encrypted: EncryptedData = JSON.parse(encryptedValue); const salt = Buffer.from(encrypted.salt, "hex"); if (!encrypted.recordId) { throw new Error( `Encrypted field missing recordId context - data corruption or legacy format not supported`, ); } const context = `${encrypted.recordId}:${fieldName}`; const fieldKey = Buffer.from( crypto.hkdfSync("sha256", masterKey, salt, context, this.KEY_LENGTH), ); const decipher = crypto.createDecipheriv( this.ALGORITHM, fieldKey, Buffer.from(encrypted.iv, "hex"), ) as crypto.DecipherGCM; decipher.setAuthTag(Buffer.from(encrypted.tag, "hex")); let decrypted = decipher.update(encrypted.data, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } static shouldEncryptField(tableName: string, fieldName: string): boolean { const fields = this.ENCRYPTED_FIELDS[tableName as keyof typeof this.ENCRYPTED_FIELDS]; return fields ? fields.has(fieldName) : false; } } export { FieldCrypto, type EncryptedData }; ================================================ FILE: src/backend/utils/lazy-field-encryption.ts ================================================ import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; interface DatabaseInstance { prepare: (sql: string) => { all: (param?: unknown) => unknown[]; get: (param?: unknown) => unknown; run: (...params: unknown[]) => unknown; }; } export class LazyFieldEncryption { private static readonly LEGACY_FIELD_NAME_MAP: Record = { key_password: "keyPassword", private_key: "privateKey", public_key: "publicKey", password_hash: "passwordHash", client_secret: "clientSecret", totp_secret: "totpSecret", totp_backup_codes: "totpBackupCodes", oidc_identifier: "oidcIdentifier", keyPassword: "key_password", privateKey: "private_key", publicKey: "public_key", passwordHash: "password_hash", clientSecret: "client_secret", totpSecret: "totp_secret", totpBackupCodes: "totp_backup_codes", oidcIdentifier: "oidc_identifier", }; static isPlaintextField(value: string): boolean { if (!value) return false; try { const parsed = JSON.parse(value); if ( parsed && typeof parsed === "object" && parsed.data && parsed.iv && parsed.tag && parsed.salt && parsed.recordId ) { return false; } return true; } catch { return true; } } static safeGetFieldValue( fieldValue: string, userKEK: Buffer, recordId: string, fieldName: string, ): string { if (!fieldValue) return ""; if (this.isPlaintextField(fieldValue)) { return fieldValue; } else { try { const decrypted = FieldCrypto.decryptField( fieldValue, userKEK, recordId, fieldName, ); return decrypted; } catch (error) { const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; if (legacyFieldName) { try { const decrypted = FieldCrypto.decryptField( fieldValue, userKEK, recordId, legacyFieldName, ); return decrypted; } catch { // expected - legacy decryption may fail, try other methods } } const sensitiveFields = [ "totpSecret", "totpBackupCodes", "password", "key", "keyPassword", "privateKey", "publicKey", "clientSecret", "oidcIdentifier", ]; if (sensitiveFields.includes(fieldName)) { return ""; } databaseLogger.error("Failed to decrypt field", error, { operation: "lazy_encryption_decrypt_failed", recordId, fieldName, error: error instanceof Error ? error.message : "Unknown error", }); throw error; } } } static migrateFieldToEncrypted( fieldValue: string, userKEK: Buffer, recordId: string, fieldName: string, ): { encrypted: string; wasPlaintext: boolean; wasLegacyEncryption: boolean; } { if (!fieldValue) { return { encrypted: "", wasPlaintext: false, wasLegacyEncryption: false }; } if (this.isPlaintextField(fieldValue)) { try { const encrypted = FieldCrypto.encryptField( fieldValue, userKEK, recordId, fieldName, ); return { encrypted, wasPlaintext: true, wasLegacyEncryption: false }; } catch (error) { databaseLogger.error("Failed to encrypt plaintext field", error, { operation: "lazy_encryption_migrate_failed", recordId, fieldName, error: error instanceof Error ? error.message : "Unknown error", }); throw error; } } else { try { FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName); return { encrypted: fieldValue, wasPlaintext: false, wasLegacyEncryption: false, }; } catch { const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; if (legacyFieldName) { try { const decrypted = FieldCrypto.decryptField( fieldValue, userKEK, recordId, legacyFieldName, ); const reencrypted = FieldCrypto.encryptField( decrypted, userKEK, recordId, fieldName, ); return { encrypted: reencrypted, wasPlaintext: false, wasLegacyEncryption: true, }; } catch { // expected - re-encryption may fail, return original } } return { encrypted: fieldValue, wasPlaintext: false, wasLegacyEncryption: false, }; } } } static migrateRecordSensitiveFields( record: Record, sensitiveFields: string[], userKEK: Buffer, recordId: string, ): { updatedRecord: Record; migratedFields: string[]; needsUpdate: boolean; } { const updatedRecord = { ...record }; const migratedFields: string[] = []; let needsUpdate = false; for (const fieldName of sensitiveFields) { const column = this.propertyToColumn(fieldName); const fieldValue = record[column] ?? record[fieldName]; if (fieldValue) { try { const { encrypted, wasPlaintext, wasLegacyEncryption } = this.migrateFieldToEncrypted( fieldValue as string, userKEK, recordId, fieldName, ); if (wasPlaintext || wasLegacyEncryption) { updatedRecord[column] = encrypted; migratedFields.push(fieldName); needsUpdate = true; } } catch (error) { databaseLogger.error("Failed to migrate record field", error, { operation: "lazy_encryption_record_field_failed", recordId, fieldName, }); } } } return { updatedRecord, migratedFields, needsUpdate }; } private static readonly PROPERTY_TO_COLUMN: Record = { keyPassword: "key_password", privateKey: "private_key", publicKey: "public_key", sudoPassword: "sudo_password", autostartPassword: "autostart_password", autostartKey: "autostart_key", autostartKeyPassword: "autostart_key_password", totpSecret: "totp_secret", totpBackupCodes: "totp_backup_codes", clientSecret: "client_secret", oidcIdentifier: "oidc_identifier", }; static getSensitiveFieldsForTable(tableName: string): string[] { const sensitiveFieldsMap: Record = { ssh_data: [ "password", "key", "keyPassword", "sudoPassword", "autostartPassword", "autostartKey", "autostartKeyPassword", ], ssh_credentials: [ "password", "key", "keyPassword", "privateKey", "publicKey", ], users: ["totpSecret", "totpBackupCodes"], }; return sensitiveFieldsMap[tableName] || []; } static propertyToColumn(propertyName: string): string { return this.PROPERTY_TO_COLUMN[propertyName] || propertyName; } static fieldNeedsMigration( fieldValue: string, userKEK: Buffer, recordId: string, fieldName: string, ): boolean { if (!fieldValue) return false; if (this.isPlaintextField(fieldValue)) { return true; } try { FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName); return false; } catch { const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName]; if (legacyFieldName) { try { FieldCrypto.decryptField( fieldValue, userKEK, recordId, legacyFieldName, ); return true; } catch { return false; } } return false; } } static async checkUserNeedsMigration( userId: string, userKEK: Buffer, db: DatabaseInstance, ): Promise<{ needsMigration: boolean; plaintextFields: Array<{ table: string; recordId: string; fields: string[]; }>; }> { const plaintextFields: Array<{ table: string; recordId: string; fields: string[]; }> = []; let needsMigration = false; try { const sshHosts = db .prepare("SELECT * FROM ssh_data WHERE user_id = ?") .all(userId) as Array< Record & { id: string | number } >; for (const host of sshHosts) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data"); const hostPlaintextFields: string[] = []; for (const field of sensitiveFields) { const column = this.propertyToColumn(field); if ( host[column] && this.fieldNeedsMigration( host[column] as string, userKEK, host.id.toString(), field, ) ) { hostPlaintextFields.push(field); needsMigration = true; } } if (hostPlaintextFields.length > 0) { plaintextFields.push({ table: "ssh_data", recordId: host.id.toString(), fields: hostPlaintextFields, }); } } const sshCredentials = db .prepare("SELECT * FROM ssh_credentials WHERE user_id = ?") .all(userId) as Array< Record & { id: string | number } >; for (const credential of sshCredentials) { const sensitiveFields = this.getSensitiveFieldsForTable("ssh_credentials"); const credentialPlaintextFields: string[] = []; for (const field of sensitiveFields) { const column = this.propertyToColumn(field); if ( credential[column] && this.fieldNeedsMigration( credential[column] as string, userKEK, credential.id.toString(), field, ) ) { credentialPlaintextFields.push(field); needsMigration = true; } } if (credentialPlaintextFields.length > 0) { plaintextFields.push({ table: "ssh_credentials", recordId: credential.id.toString(), fields: credentialPlaintextFields, }); } } const user = db.prepare("SELECT * FROM users WHERE id = ?").get(userId); if (user) { const sensitiveFields = this.getSensitiveFieldsForTable("users"); const userPlaintextFields: string[] = []; for (const field of sensitiveFields) { const column = this.propertyToColumn(field); if ( user[column] && this.fieldNeedsMigration(user[column], userKEK, userId, field) ) { userPlaintextFields.push(field); needsMigration = true; } } if (userPlaintextFields.length > 0) { plaintextFields.push({ table: "users", recordId: userId, fields: userPlaintextFields, }); } } return { needsMigration, plaintextFields }; } catch (error) { databaseLogger.error("Failed to check user migration needs", error, { operation: "lazy_encryption_user_check_failed", userId, error: error instanceof Error ? error.message : "Unknown error", }); return { needsMigration: false, plaintextFields: [] }; } } } ================================================ FILE: src/backend/utils/logger.ts ================================================ import chalk from "chalk"; export type LogLevel = "debug" | "info" | "warn" | "error" | "success"; export interface LogContext { service?: string; operation?: string; userId?: string; hostId?: number; tunnelName?: string; sessionId?: string; requestId?: string; duration?: number; [key: string]: unknown; } const SENSITIVE_FIELDS = [ "password", "passphrase", "key", "privateKey", "publicKey", "token", "secret", "clientSecret", "keyPassword", "autostartPassword", "autostartKey", "autostartKeyPassword", "credentialId", "authToken", "jwt", "session", "cookie", ]; const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"]; export class Logger { private serviceName: string; private serviceIcon: string; private serviceColor: string; private logCounts = new Map(); private readonly RATE_LIMIT_WINDOW = 60000; private readonly RATE_LIMIT_MAX = 10; constructor(serviceName: string, serviceIcon: string, serviceColor: string) { this.serviceName = serviceName; this.serviceIcon = serviceIcon; this.serviceColor = serviceColor; } private getTimeStamp(): string { return chalk.gray(`[${new Date().toLocaleTimeString()}]`); } private sanitizeContext(context: LogContext): LogContext { const sanitized = { ...context }; for (const field of SENSITIVE_FIELDS) { if (sanitized[field] !== undefined) { if ( typeof sanitized[field] === "string" && sanitized[field].length > 0 ) { sanitized[field] = "[MASKED]"; } else if (typeof sanitized[field] === "boolean") { sanitized[field] = sanitized[field] ? "[PRESENT]" : "[ABSENT]"; } else { sanitized[field] = "[MASKED]"; } } } for (const field of TRUNCATE_FIELDS) { if ( sanitized[field] && typeof sanitized[field] === "string" && sanitized[field].length > 100 ) { sanitized[field] = sanitized[field].substring(0, 100) + "..."; } } return sanitized; } private formatMessage( level: LogLevel, message: string, context?: LogContext, ): string { const timestamp = this.getTimeStamp(); const levelColor = this.getLevelColor(level); const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`); const levelTag = levelColor(`[${level.toUpperCase()}]`); let contextStr = ""; if (context) { const sanitizedContext = this.sanitizeContext(context); const contextParts = []; if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`); if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`); if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`); if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`); if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`); if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`); if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`); if (contextParts.length > 0) { contextStr = chalk.gray(` [${contextParts.join(",")}]`); } } return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; } private getLevelColor(level: LogLevel): chalk.Chalk { switch (level) { case "debug": return chalk.magenta; case "info": return chalk.cyan; case "warn": return chalk.yellow; case "error": return chalk.redBright; case "success": return chalk.greenBright; default: return chalk.white; } } private shouldLog(level: LogLevel, message: string): boolean { if (level === "debug" && process.env.NODE_ENV === "production") { return false; } const now = Date.now(); const logKey = `${level}:${message}`; const logInfo = this.logCounts.get(logKey); if (logInfo) { if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) { logInfo.count++; if (logInfo.count > this.RATE_LIMIT_MAX) { return false; } } else { logInfo.count = 1; logInfo.lastLog = now; } } else { this.logCounts.set(logKey, { count: 1, lastLog: now }); } return true; } debug(message: string, context?: LogContext): void { if (!this.shouldLog("debug", message)) return; console.debug(this.formatMessage("debug", message, context)); } info(message: string, context?: LogContext): void { if (!this.shouldLog("info", message)) return; console.log(this.formatMessage("info", message, context)); } warn(message: string, context?: LogContext): void { if (!this.shouldLog("warn", message)) return; console.warn(this.formatMessage("warn", message, context)); } error(message: string, error?: unknown, context?: LogContext): void { if (!this.shouldLog("error", message)) return; console.error(this.formatMessage("error", message, context)); if (error) { console.error(error); } } success(message: string, context?: LogContext): void { if (!this.shouldLog("success", message)) return; console.log(this.formatMessage("success", message, context)); } auth(message: string, context?: LogContext): void { this.info(`AUTH: ${message}`, { ...context, operation: "auth" }); } db(message: string, context?: LogContext): void { this.info(`DB: ${message}`, { ...context, operation: "database" }); } ssh(message: string, context?: LogContext): void { this.info(`SSH: ${message}`, { ...context, operation: "ssh" }); } tunnel(message: string, context?: LogContext): void { this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" }); } file(message: string, context?: LogContext): void { this.info(`FILE: ${message}`, { ...context, operation: "file" }); } api(message: string, context?: LogContext): void { this.info(`API: ${message}`, { ...context, operation: "api" }); } request(message: string, context?: LogContext): void { this.info(`REQUEST: ${message}`, { ...context, operation: "request" }); } response(message: string, context?: LogContext): void { this.info(`RESPONSE: ${message}`, { ...context, operation: "response" }); } connection(message: string, context?: LogContext): void { this.info(`CONNECTION: ${message}`, { ...context, operation: "connection", }); } disconnect(message: string, context?: LogContext): void { this.info(`DISCONNECT: ${message}`, { ...context, operation: "disconnect", }); } retry(message: string, context?: LogContext): void { this.warn(`RETRY: ${message}`, { ...context, operation: "retry" }); } } export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1"); export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9"); export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7"); export const fileLogger = new Logger("FILE", "📁", "#f59e0b"); export const statsLogger = new Logger("STATS", "📊", "#22c55e"); export const apiLogger = new Logger("API", "🌐", "#3b82f6"); export const authLogger = new Logger("AUTH", "🔐", "#ef4444"); 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 logger = systemLogger; ================================================ FILE: src/backend/utils/login-rate-limiter.ts ================================================ interface LoginAttempt { count: number; firstAttempt: number; lockedUntil?: number; } class LoginRateLimiter { private ipAttempts = new Map(); private usernameAttempts = new Map(); private totpAttempts = new Map(); private resetCodeAttempts = new Map(); private readonly MAX_ATTEMPTS = 5; private readonly WINDOW_MS = 10 * 60 * 1000; private readonly LOCKOUT_MS = 10 * 60 * 1000; private readonly TOTP_MAX_ATTEMPTS = 5; private readonly TOTP_WINDOW_MS = 1 * 60 * 1000; private readonly TOTP_LOCKOUT_MS = 5 * 60 * 1000; private readonly RESET_CODE_MAX_ATTEMPTS = 5; private readonly RESET_CODE_WINDOW_MS = 1 * 60 * 1000; private readonly RESET_CODE_LOCKOUT_MS = 5 * 60 * 1000; constructor() { setInterval(() => this.cleanup(), 5 * 60 * 1000); } private cleanup(): void { const now = Date.now(); for (const [ip, attempt] of this.ipAttempts.entries()) { if (attempt.lockedUntil && attempt.lockedUntil < now) { this.ipAttempts.delete(ip); } else if ( !attempt.lockedUntil && now - attempt.firstAttempt > this.WINDOW_MS ) { this.ipAttempts.delete(ip); } } for (const [username, attempt] of this.usernameAttempts.entries()) { if (attempt.lockedUntil && attempt.lockedUntil < now) { this.usernameAttempts.delete(username); } else if ( !attempt.lockedUntil && now - attempt.firstAttempt > this.WINDOW_MS ) { this.usernameAttempts.delete(username); } } for (const [userId, attempt] of this.totpAttempts.entries()) { if (attempt.lockedUntil && attempt.lockedUntil < now) { this.totpAttempts.delete(userId); } else if ( !attempt.lockedUntil && now - attempt.firstAttempt > this.TOTP_WINDOW_MS ) { this.totpAttempts.delete(userId); } } for (const [username, attempt] of this.resetCodeAttempts.entries()) { if (attempt.lockedUntil && attempt.lockedUntil < now) { this.resetCodeAttempts.delete(username); } else if ( !attempt.lockedUntil && now - attempt.firstAttempt > this.RESET_CODE_WINDOW_MS ) { this.resetCodeAttempts.delete(username); } } } recordFailedAttempt(ip: string, username?: string): void { const now = Date.now(); const ipAttempt = this.ipAttempts.get(ip); if (!ipAttempt) { this.ipAttempts.set(ip, { count: 1, firstAttempt: now, }); } else if (now - ipAttempt.firstAttempt > this.WINDOW_MS) { this.ipAttempts.set(ip, { count: 1, firstAttempt: now, }); } else { ipAttempt.count++; if (ipAttempt.count >= this.MAX_ATTEMPTS) { ipAttempt.lockedUntil = now + this.LOCKOUT_MS; } } if (username) { const userAttempt = this.usernameAttempts.get(username); if (!userAttempt) { this.usernameAttempts.set(username, { count: 1, firstAttempt: now, }); } else if (now - userAttempt.firstAttempt > this.WINDOW_MS) { this.usernameAttempts.set(username, { count: 1, firstAttempt: now, }); } else { userAttempt.count++; if (userAttempt.count >= this.MAX_ATTEMPTS) { userAttempt.lockedUntil = now + this.LOCKOUT_MS; } } } } resetAttempts(ip: string, username?: string): void { this.ipAttempts.delete(ip); if (username) { this.usernameAttempts.delete(username); } } isLocked( ip: string, username?: string, ): { locked: boolean; remainingTime?: number } { const now = Date.now(); const ipAttempt = this.ipAttempts.get(ip); if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { return { locked: true, remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), }; } if (username) { const userAttempt = this.usernameAttempts.get(username); if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) { return { locked: true, remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000), }; } } return { locked: false }; } getRemainingAttempts(ip: string, username?: string): number { const now = Date.now(); let minRemaining = this.MAX_ATTEMPTS; const ipAttempt = this.ipAttempts.get(ip); if (ipAttempt && now - ipAttempt.firstAttempt <= this.WINDOW_MS) { const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count); minRemaining = Math.min(minRemaining, ipRemaining); } if (username) { const userAttempt = this.usernameAttempts.get(username); if (userAttempt && now - userAttempt.firstAttempt <= this.WINDOW_MS) { const userRemaining = Math.max( 0, this.MAX_ATTEMPTS - userAttempt.count, ); minRemaining = Math.min(minRemaining, userRemaining); } } return minRemaining; } recordFailedTOTPAttempt(userId: string): void { const now = Date.now(); const totpAttempt = this.totpAttempts.get(userId); if (!totpAttempt) { this.totpAttempts.set(userId, { count: 1, firstAttempt: now, }); } else if (now - totpAttempt.firstAttempt > this.TOTP_WINDOW_MS) { this.totpAttempts.set(userId, { count: 1, firstAttempt: now, }); } else { totpAttempt.count++; if (totpAttempt.count >= this.TOTP_MAX_ATTEMPTS) { totpAttempt.lockedUntil = now + this.TOTP_LOCKOUT_MS; } } } resetTOTPAttempts(userId: string): void { this.totpAttempts.delete(userId); } isTOTPLocked(userId: string): { locked: boolean; remainingTime?: number } { const now = Date.now(); const totpAttempt = this.totpAttempts.get(userId); if (totpAttempt?.lockedUntil && totpAttempt.lockedUntil > now) { return { locked: true, remainingTime: Math.ceil((totpAttempt.lockedUntil - now) / 1000), }; } return { locked: false }; } getRemainingTOTPAttempts(userId: string): number { const now = Date.now(); const totpAttempt = this.totpAttempts.get(userId); if (totpAttempt && now - totpAttempt.firstAttempt <= this.TOTP_WINDOW_MS) { return Math.max(0, this.TOTP_MAX_ATTEMPTS - totpAttempt.count); } return this.TOTP_MAX_ATTEMPTS; } recordResetCodeAttempt(username: string): void { const now = Date.now(); const resetAttempt = this.resetCodeAttempts.get(username); if (!resetAttempt) { this.resetCodeAttempts.set(username, { count: 1, firstAttempt: now, }); } else if (now - resetAttempt.firstAttempt > this.RESET_CODE_WINDOW_MS) { this.resetCodeAttempts.set(username, { count: 1, firstAttempt: now, }); } else { resetAttempt.count++; if (resetAttempt.count >= this.RESET_CODE_MAX_ATTEMPTS) { resetAttempt.lockedUntil = now + this.RESET_CODE_LOCKOUT_MS; } } } resetResetCodeAttempts(username: string): void { this.resetCodeAttempts.delete(username); } isResetCodeLocked(username: string): { locked: boolean; remainingTime?: number; } { const now = Date.now(); const resetAttempt = this.resetCodeAttempts.get(username); if (resetAttempt?.lockedUntil && resetAttempt.lockedUntil > now) { return { locked: true, remainingTime: Math.ceil((resetAttempt.lockedUntil - now) / 1000), }; } return { locked: false }; } getRemainingResetCodeAttempts(username: string): number { const now = Date.now(); const resetAttempt = this.resetCodeAttempts.get(username); if ( resetAttempt && now - resetAttempt.firstAttempt <= this.RESET_CODE_WINDOW_MS ) { return Math.max(0, this.RESET_CODE_MAX_ATTEMPTS - resetAttempt.count); } return this.RESET_CODE_MAX_ATTEMPTS; } } export const loginRateLimiter = new LoginRateLimiter(); ================================================ FILE: src/backend/utils/opkssh-binary-manager.ts ================================================ import { promises as fs } from "fs"; import path from "path"; import { createWriteStream } from "fs"; import { pipeline } from "stream/promises"; import { systemLogger } from "./logger.js"; const OPKSSH_REPO = "openpubkey/opkssh"; function getBinaryDir(): string { const dataDir = process.env.DATA_DIR || path.join(process.cwd(), "db", "data"); return path.join(dataDir, "opkssh"); } function getVersionFile(): string { return path.join(getBinaryDir(), "version.txt"); } interface GitHubAsset { name: string; browser_download_url: string; } interface GitHubRelease { tag_name: string; assets: GitHubAsset[]; } export class OPKSSHBinaryManager { private static binaryPath: string | null = null; static async ensureBinary(): Promise { if (this.binaryPath) { return this.binaryPath; } const binaryName = this.getBinaryName(); const expectedPath = path.join(getBinaryDir(), binaryName); try { await fs.access(expectedPath); const needsUpdate = await this.checkForUpdate(); if (needsUpdate) { systemLogger.info("Newer OPKSSH version available, updating...", { operation: "opkssh_binary_update_start", }); await this.downloadBinary(); } this.binaryPath = expectedPath; return expectedPath; } catch { systemLogger.info("OPKSSH binary not found, downloading...", { operation: "opkssh_binary_download_start", }); await this.downloadBinary(); this.binaryPath = expectedPath; return expectedPath; } } static async downloadBinary(): Promise { try { await fs.mkdir(getBinaryDir(), { recursive: true }); const release = await this.getLatestRelease(); const asset = this.findMatchingAsset(release.assets); if (!asset) { throw new Error( `No matching OPKSSH binary found for platform ${process.platform} ${process.arch}`, ); } const binaryName = this.getBinaryName(); const binaryPath = path.join(getBinaryDir(), binaryName); const response = await fetch(asset.browser_download_url); if (!response.ok) { throw new Error(`Failed to download: ${response.statusText}`); } if (!response.body) { throw new Error("Response body is null"); } const fileStream = createWriteStream(binaryPath); await pipeline( response.body as unknown as NodeJS.ReadableStream, fileStream, ); await fs.chmod(binaryPath, 0o755); await fs.writeFile(getVersionFile(), release.tag_name, "utf8"); systemLogger.info( `OPKSSH binary downloaded successfully to ${binaryPath}`, { operation: "opkssh_binary_download_complete", path: binaryPath, version: release.tag_name, }, ); } catch (error) { systemLogger.error("Failed to download OPKSSH binary", error, { operation: "opkssh_binary_download_error", }); throw error; } } static getBinaryPath(): string { if (!this.binaryPath) { throw new Error( "OPKSSH binary not initialized. Call ensureBinary() first.", ); } return this.binaryPath; } private static async checkForUpdate(): Promise { try { let localVersion: string | null = null; try { localVersion = await fs.readFile(getVersionFile(), "utf8"); localVersion = localVersion.trim(); } catch { return true; } const release = await this.getLatestRelease(); const latestVersion = release.tag_name; if (localVersion !== latestVersion) { return true; } return false; } catch (error) { systemLogger.warn("Failed to check for OPKSSH updates", { operation: "opkssh_update_check_failed", error: error instanceof Error ? error.message : "Unknown error", }); return false; } } private static async getLatestRelease(): Promise { const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/latest`; const response = await fetch(url, { headers: { "User-Agent": "Termix", }, }); if (!response.ok) { throw new Error(`Failed to fetch release info: ${response.statusText}`); } return (await response.json()) as GitHubRelease; } private static findMatchingAsset(assets: GitHubAsset[]): GitHubAsset | null { const platform = process.platform; const arch = process.arch; const osMap: Record = { win32: "windows", linux: "linux", darwin: "darwin", }; const archMap: Record = { x64: "amd64", arm64: "arm64", }; const mappedOs = osMap[platform]; const mappedArch = archMap[arch]; if (!mappedOs || !mappedArch) { return null; } const patterns = [ `opkssh-${mappedOs}-${mappedArch}.exe`, `opkssh-${mappedOs}-${mappedArch}`, `opkssh_${mappedOs}_${mappedArch}.exe`, `opkssh_${mappedOs}_${mappedArch}`, ]; for (const pattern of patterns) { const asset = assets.find( (a) => a.name.toLowerCase() === pattern.toLowerCase(), ); if (asset) { return asset; } } return null; } private static getBinaryName(): string { const platform = process.platform; const arch = process.arch; const osMap: Record = { win32: "windows", linux: "linux", darwin: "darwin", }; const archMap: Record = { x64: "amd64", arm64: "arm64", }; const mappedOs = osMap[platform] || platform; const mappedArch = archMap[arch] || arch; const extension = platform === "win32" ? ".exe" : ""; return `opkssh-${mappedOs}-${mappedArch}${extension}`; } } ================================================ FILE: src/backend/utils/permission-manager.ts ================================================ import type { Request, Response, NextFunction } from "express"; import { db } from "../database/db/index.js"; import { hostAccess, roles, userRoles, hosts, users, } from "../database/db/schema.js"; import { eq, and, or, isNull, gte, sql } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; interface AuthenticatedRequest extends Request { userId?: string; dataKey?: Buffer; } interface HostAccessInfo { hasAccess: boolean; isOwner: boolean; isShared: boolean; permissionLevel?: "view"; expiresAt?: string | null; } interface PermissionCheckResult { allowed: boolean; reason?: string; } class PermissionManager { private static instance: PermissionManager; private permissionCache: Map< string, { permissions: string[]; timestamp: number } >; private readonly CACHE_TTL = 5 * 60 * 1000; private constructor() { this.permissionCache = new Map(); setInterval(() => { this.cleanupExpiredAccess().catch((error) => { databaseLogger.error( "Failed to run periodic host access cleanup", error, { operation: "host_access_cleanup_periodic", }, ); }); }, 60 * 1000); setInterval(() => { this.clearPermissionCache(); }, this.CACHE_TTL); } static getInstance(): PermissionManager { if (!this.instance) { this.instance = new PermissionManager(); } return this.instance; } private async cleanupExpiredAccess(): Promise { try { const now = new Date().toISOString(); await db .delete(hostAccess) .where( and( sql`${hostAccess.expiresAt} IS NOT NULL`, sql`${hostAccess.expiresAt} <= ${now}`, ), ); } catch (error) { databaseLogger.error("Failed to cleanup expired host access", error, { operation: "host_access_cleanup_failed", }); } } private clearPermissionCache(): void { this.permissionCache.clear(); } invalidateUserPermissionCache(userId: string): void { this.permissionCache.delete(userId); } async getUserPermissions(userId: string): Promise { const cached = this.permissionCache.get(userId); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.permissions; } try { const userRoleRecords = await db .select({ permissions: roles.permissions, }) .from(userRoles) .innerJoin(roles, eq(userRoles.roleId, roles.id)) .where(eq(userRoles.userId, userId)); const allPermissions = new Set(); for (const record of userRoleRecords) { try { const permissions = JSON.parse(record.permissions) as string[]; for (const perm of permissions) { allPermissions.add(perm); } } catch (parseError) { databaseLogger.warn("Failed to parse role permissions", { operation: "get_user_permissions", userId, error: parseError, }); } } const permissionsArray = Array.from(allPermissions); this.permissionCache.set(userId, { permissions: permissionsArray, timestamp: Date.now(), }); return permissionsArray; } catch (error) { databaseLogger.error("Failed to get user permissions", error, { operation: "get_user_permissions", userId, }); return []; } } async hasPermission(userId: string, permission: string): Promise { const userPermissions = await this.getUserPermissions(userId); if (userPermissions.includes("*")) { return true; } if (userPermissions.includes(permission)) { return true; } const parts = permission.split("."); for (let i = parts.length; i > 0; i--) { const wildcardPermission = parts.slice(0, i).join(".") + ".*"; if (userPermissions.includes(wildcardPermission)) { return true; } } return false; } async canAccessHost( userId: string, hostId: number, action: "read" | "write" | "execute" | "delete" | "share" = "read", ): Promise { try { const host = await db .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); if (host.length > 0) { return { hasAccess: true, isOwner: true, isShared: false, }; } const userRoleIds = await db .select({ roleId: userRoles.roleId }) .from(userRoles) .where(eq(userRoles.userId, userId)); const roleIds = userRoleIds.map((r) => r.roleId); const now = new Date().toISOString(); const sharedAccess = await db .select() .from(hostAccess) .where( and( eq(hostAccess.hostId, hostId), or( eq(hostAccess.userId, userId), roleIds.length > 0 ? sql`${hostAccess.roleId} IN (${sql.join( roleIds.map((id) => sql`${id}`), sql`, `, )})` : sql`false`, ), or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .limit(1); if (sharedAccess.length > 0) { const access = sharedAccess[0]; const hostOwnerCheck = await db .select({ ownerId: hosts.userId }) .from(hosts) .where(eq(hosts.id, hostId)) .limit(1); if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) { return { hasAccess: true, isOwner: true, isShared: false, }; } if (action === "write" || action === "delete") { return { hasAccess: false, isOwner: false, isShared: true, permissionLevel: access.permissionLevel as "view", expiresAt: access.expiresAt, }; } try { await db .update(hostAccess) .set({ lastAccessedAt: now, }) .where(eq(hostAccess.id, access.id)); } catch (error) { databaseLogger.warn("Failed to update host access timestamp", { operation: "update_host_access_timestamp", error, }); } return { hasAccess: true, isOwner: false, isShared: true, permissionLevel: access.permissionLevel as "view", expiresAt: access.expiresAt, }; } return { hasAccess: false, isOwner: false, isShared: false, }; } catch (error) { databaseLogger.error("Failed to check host access", error, { operation: "can_access_host", userId, hostId, action, }); return { hasAccess: false, isOwner: false, isShared: false, }; } } async isAdmin(userId: string): Promise { try { const user = await db .select({ isAdmin: users.isAdmin }) .from(users) .where(eq(users.id, userId)) .limit(1); if (user.length > 0 && user[0].isAdmin) { return true; } const adminRoles = await db .select({ roleName: roles.name }) .from(userRoles) .innerJoin(roles, eq(userRoles.roleId, roles.id)) .where( and( eq(userRoles.userId, userId), or(eq(roles.name, "admin"), eq(roles.name, "super_admin")), ), ); return adminRoles.length > 0; } catch (error) { databaseLogger.error("Failed to check admin status", error, { operation: "is_admin", userId, }); return false; } } requirePermission(permission: string) { return async ( req: AuthenticatedRequest, res: Response, next: NextFunction, ) => { const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Not authenticated" }); } const hasPermission = await this.hasPermission(userId, permission); if (!hasPermission) { databaseLogger.warn("Permission denied", { operation: "permission_check", userId, permission, path: req.path, }); return res.status(403).json({ error: "Insufficient permissions", required: permission, }); } next(); }; } requireHostAccess( hostIdParam: string = "id", action: "read" | "write" | "execute" | "delete" | "share" = "read", ) { return async ( req: AuthenticatedRequest, res: Response, next: NextFunction, ) => { const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Not authenticated" }); } const hostIdValue = Array.isArray(req.params[hostIdParam]) ? req.params[hostIdParam][0] : req.params[hostIdParam]; const hostId = parseInt(hostIdValue, 10); if (isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } const accessInfo = await this.canAccessHost(userId, hostId, action); if (!accessInfo.hasAccess) { databaseLogger.warn("Host access denied", { operation: "host_access_check", userId, hostId, action, }); return res.status(403).json({ error: "Access denied to host", hostId, action, }); } (req as unknown as { hostAccessInfo: HostAccessInfo }).hostAccessInfo = accessInfo; next(); }; } requireAdmin() { return async ( req: AuthenticatedRequest, res: Response, next: NextFunction, ) => { const userId = req.userId; if (!userId) { return res.status(401).json({ error: "Not authenticated" }); } const isAdmin = await this.isAdmin(userId); if (!isAdmin) { databaseLogger.warn("Admin access denied", { operation: "admin_check", userId, path: req.path, }); return res.status(403).json({ error: "Admin access required" }); } next(); }; } } export { PermissionManager }; export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult }; ================================================ FILE: src/backend/utils/proxy-agent.ts ================================================ import { HttpsProxyAgent } from "https-proxy-agent"; import type { Agent } from "http"; export function getProxyAgent(targetUrl?: string): Agent | undefined { const proxyUrl = process.env.https_proxy || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.HTTP_PROXY; if (!proxyUrl) return undefined; if (targetUrl) { const noProxy = process.env.no_proxy || process.env.NO_PROXY || ""; const hostname = new URL(targetUrl).hostname.toLowerCase(); for (const entry of noProxy.split(",")) { const trimmed = entry.trim().toLowerCase(); if (!trimmed) continue; const normalized = trimmed.replace(/^\*\./, "").replace(/^\./, ""); if (hostname === normalized || hostname.endsWith(`.${normalized}`)) { return undefined; } } } return new HttpsProxyAgent(proxyUrl); } ================================================ FILE: src/backend/utils/proxy-helper.ts ================================================ import { SocksClient } from "socks"; import type { SocksClientOptions } from "socks"; import net from "net"; import { sshLogger } from "./logger.js"; import type { ProxyNode } from "../../types/index.js"; export interface SOCKS5Config { useSocks5?: boolean; socks5Host?: string; socks5Port?: number; socks5Username?: string; socks5Password?: string; socks5ProxyChain?: ProxyNode[]; } export async function createProxyConnection( targetHost: string, targetPort: number, socks5Config: SOCKS5Config, ): Promise { if (!socks5Config.useSocks5) { return null; } if ( socks5Config.socks5ProxyChain && socks5Config.socks5ProxyChain.length > 0 ) { return createMixedProxyChainConnection( targetHost, targetPort, socks5Config.socks5ProxyChain, ); } if (socks5Config.socks5Host) { return createSingleProxyConnection(targetHost, targetPort, socks5Config); } return null; } export const createSocks5Connection = createProxyConnection; async function createSingleProxyConnection( targetHost: string, targetPort: number, socks5Config: SOCKS5Config, ): Promise { const socksOptions: SocksClientOptions = { proxy: { host: socks5Config.socks5Host!, port: socks5Config.socks5Port || 1080, type: 5, userId: socks5Config.socks5Username, password: socks5Config.socks5Password, }, command: "connect", destination: { host: targetHost, port: targetPort, }, }; try { const info = await SocksClient.createConnection(socksOptions); return info.socket; } catch (error) { sshLogger.error("SOCKS5 connection failed", error, { operation: "socks5_connect_failed", proxyHost: socks5Config.socks5Host, proxyPort: socks5Config.socks5Port || 1080, targetHost, targetPort, errorMessage: error instanceof Error ? error.message : "Unknown error", }); throw error; } } export async function createHttpConnectConnection( targetHost: string, targetPort: number, proxyHost: string, proxyPort: number, username?: string, password?: string, existingSocket?: net.Socket, ): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject( new Error( `HTTP CONNECT proxy timeout connecting to ${proxyHost}:${proxyPort}`, ), ); }, 15000); function sendConnect(socket: net.Socket) { let connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`; if (username && password) { const credentials = Buffer.from(`${username}:${password}`).toString( "base64", ); connectReq += `Proxy-Authorization: Basic ${credentials}\r\n`; } connectReq += "\r\n"; let responseBuffer = ""; function onData(chunk: Buffer) { responseBuffer += chunk.toString("utf8"); const headerEnd = responseBuffer.indexOf("\r\n\r\n"); if (headerEnd === -1) return; clearTimeout(timeout); socket.removeListener("data", onData); socket.removeListener("error", onError); const statusLine = responseBuffer.slice( 0, responseBuffer.indexOf("\r\n"), ); const statusCode = parseInt(statusLine.split(" ")[1], 10); if (statusCode === 200) { resolve(socket); } else { socket.destroy(); reject( new Error( `HTTP CONNECT proxy returned ${statusCode}: ${statusLine}`, ), ); } } function onError(err: Error) { clearTimeout(timeout); reject(new Error(`HTTP CONNECT proxy error: ${err.message}`)); } socket.on("data", onData); socket.on("error", onError); socket.write(connectReq); } if (existingSocket) { sendConnect(existingSocket); } else { const socket = net.connect(proxyPort, proxyHost, () => { sendConnect(socket); }); socket.on("error", (err) => { clearTimeout(timeout); reject(new Error(`HTTP CONNECT proxy TCP error: ${err.message}`)); }); } }); } export async function createMixedProxyChainConnection( targetHost: string, targetPort: number, proxyChain: ProxyNode[], ): Promise { if (proxyChain.length === 0) { throw new Error("Proxy chain is empty"); } const hasMixedTypes = proxyChain.some((p) => p.type === "http"); if (!hasMixedTypes) { return createPureSocksChainConnection(targetHost, targetPort, proxyChain); } return createHopByHopConnection(targetHost, targetPort, proxyChain); } async function createPureSocksChainConnection( targetHost: string, targetPort: number, proxyChain: ProxyNode[], ): Promise { try { const info = await SocksClient.createConnectionChain({ proxies: proxyChain.map((p) => ({ host: p.host, port: p.port, type: p.type as 4 | 5, userId: p.username, password: p.password, timeout: 10000, })), command: "connect", destination: { host: targetHost, port: targetPort, }, }); return info.socket; } catch (error) { sshLogger.error("SOCKS proxy chain connection failed", error, { operation: "socks5_chain_connect_failed", chainLength: proxyChain.length, targetHost, targetPort, errorMessage: error instanceof Error ? error.message : "Unknown error", }); throw error; } } async function createHopByHopConnection( targetHost: string, targetPort: number, proxyChain: ProxyNode[], ): Promise { let currentSocket: net.Socket | null = null; try { for (let i = 0; i < proxyChain.length; i++) { const node = proxyChain[i]; const isLast = i === proxyChain.length - 1; const nextTarget = isLast ? { host: targetHost, port: targetPort } : { host: proxyChain[i + 1].host, port: proxyChain[i + 1].port }; if (node.type === "http") { currentSocket = await createHttpConnectConnection( nextTarget.host, nextTarget.port, node.host, node.port, node.username, node.password, currentSocket ?? undefined, ); } else { const socksOptions: SocksClientOptions = { proxy: { host: node.host, port: node.port, type: node.type as 4 | 5, userId: node.username, password: node.password, }, command: "connect", destination: nextTarget, }; if (currentSocket) { socksOptions.existing_socket = currentSocket; } const info = await SocksClient.createConnection(socksOptions); currentSocket = info.socket; } } if (!currentSocket) { throw new Error("Proxy chain produced no socket"); } return currentSocket; } catch (error) { if (currentSocket) { currentSocket.destroy(); } sshLogger.error("Mixed proxy chain connection failed", error, { operation: "mixed_chain_connect_failed", chainLength: proxyChain.length, targetHost, targetPort, errorMessage: error instanceof Error ? error.message : "Unknown error", }); throw error; } } export async function testProxyConnectivity(options: { singleProxy?: { host: string; port: number; type?: 4 | 5 | "http"; username?: string; password?: string; }; proxyChain?: ProxyNode[]; testTarget?: { host: string; port: number }; }): Promise<{ success: boolean; latencyMs: number }> { const target = options.testTarget ?? { host: "google.com", port: 443 }; const start = Date.now(); let socket: net.Socket | null = null; try { if (options.proxyChain && options.proxyChain.length > 0) { socket = await createMixedProxyChainConnection( target.host, target.port, options.proxyChain, ); } else if (options.singleProxy) { const proxy = options.singleProxy; if (proxy.type === "http") { socket = await createHttpConnectConnection( target.host, target.port, proxy.host, proxy.port, proxy.username, proxy.password, ); } else { const socksOptions: SocksClientOptions = { proxy: { host: proxy.host, port: proxy.port, type: (proxy.type as 4 | 5) || 5, userId: proxy.username, password: proxy.password, }, command: "connect", destination: target, timeout: 10000, }; const info = await SocksClient.createConnection(socksOptions); socket = info.socket; } } else { throw new Error("No proxy configuration provided"); } const latencyMs = Date.now() - start; socket.destroy(); return { success: true, latencyMs }; } catch (error) { if (socket) socket.destroy(); throw error; } } ================================================ FILE: src/backend/utils/request-origin.ts ================================================ import type { Request } from "express"; import type { IncomingMessage } from "http"; export function getRequestOrigin(req: Request | IncomingMessage): string { let protocol: string; const protoHeader = req.headers["x-forwarded-proto"]; if (protoHeader) { protocol = typeof protoHeader === "string" ? protoHeader.split(",")[0].trim() : protoHeader[0]; } else if ("protocol" in req && req.protocol) { protocol = req.protocol; } else { protocol = (req.socket as unknown as { encrypted?: boolean }).encrypted ? "https" : "http"; } const portHeader = req.headers["x-forwarded-port"]; let port: string | undefined = typeof portHeader === "string" ? portHeader.split(",")[0].trim() : undefined; const hostHeaderRaw = req.headers["x-forwarded-host"] || req.headers.host || "localhost"; const hostHeader = typeof hostHeaderRaw === "string" ? hostHeaderRaw.split(",")[0].trim() : String(hostHeaderRaw); if (!port && hostHeader.includes(":")) { const parts = hostHeader.split(":"); if (parts.length === 2 && !parts[0].includes("[")) { port = parts[1]; } } const hostWithoutPort = hostHeader.split(":")[0]; if (port) { const isDefaultPort = (protocol === "http" && port === "80") || (protocol === "https" && port === "443"); return isDefaultPort ? `${protocol}://${hostWithoutPort}` : `${protocol}://${hostWithoutPort}:${port}`; } return `${protocol}://${hostWithoutPort}`; } export function getRequestOriginWithForceHTTPS( req: Request | IncomingMessage, ): string { if (process.env.OIDC_FORCE_HTTPS === "true") { const origin = getRequestOrigin(req); return origin.replace(/^http:/, "https:"); } return getRequestOrigin(req); } ================================================ FILE: src/backend/utils/shared-credential-manager.ts ================================================ import { db } from "../database/db/index.js"; import { sharedCredentials, sshCredentials, hostAccess, userRoles, hosts, } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; interface CredentialData { username: string; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; } class SharedCredentialManager { private static instance: SharedCredentialManager; private constructor() {} static getInstance(): SharedCredentialManager { if (!this.instance) { this.instance = new SharedCredentialManager(); } return this.instance; } async createSharedCredentialForUser( hostAccessId: number, originalCredentialId: number, targetUserId: string, ownerId: string, ): Promise { try { const ownerDEK = DataCrypto.getUserDataKey(ownerId); if (ownerDEK) { const targetDEK = DataCrypto.getUserDataKey(targetUserId); if (!targetDEK) { await this.createPendingSharedCredential( hostAccessId, originalCredentialId, targetUserId, ); return; } const credentialData = await this.getDecryptedCredential( originalCredentialId, ownerId, ownerDEK, ); const encryptedForTarget = this.encryptCredentialForUser( credentialData, targetUserId, targetDEK, hostAccessId, ); await db.insert(sharedCredentials).values({ hostAccessId, originalCredentialId, targetUserId, ...encryptedForTarget, needsReEncryption: false, }); } else { const targetDEK = DataCrypto.getUserDataKey(targetUserId); if (!targetDEK) { await this.createPendingSharedCredential( hostAccessId, originalCredentialId, targetUserId, ); return; } const credentialData = await this.getDecryptedCredentialViaSystemKey(originalCredentialId); const encryptedForTarget = this.encryptCredentialForUser( credentialData, targetUserId, targetDEK, hostAccessId, ); await db.insert(sharedCredentials).values({ hostAccessId, originalCredentialId, targetUserId, ...encryptedForTarget, needsReEncryption: false, }); } } catch (error) { databaseLogger.error("Failed to create shared credential", error, { operation: "create_shared_credential", hostAccessId, targetUserId, }); throw error; } } async createSharedCredentialsForRole( hostAccessId: number, originalCredentialId: number, roleId: number, ownerId: string, ): Promise { try { const roleUsers = await db .select({ userId: userRoles.userId }) .from(userRoles) .where(eq(userRoles.roleId, roleId)); for (const { userId } of roleUsers) { try { await this.createSharedCredentialForUser( hostAccessId, originalCredentialId, userId, ownerId, ); } catch (error) { databaseLogger.error( "Failed to create shared credential for role member", error, { operation: "create_shared_credentials_role", hostAccessId, roleId, userId, }, ); } } } catch (error) { databaseLogger.error( "Failed to create shared credentials for role", error, { operation: "create_shared_credentials_role", hostAccessId, roleId, }, ); throw error; } } async getSharedCredentialForUser( hostId: number, userId: string, ): Promise { try { const userDEK = DataCrypto.getUserDataKey(userId); if (!userDEK) { throw new Error(`User ${userId} data not unlocked`); } const sharedCred = await db .select() .from(sharedCredentials) .innerJoin( hostAccess, eq(sharedCredentials.hostAccessId, hostAccess.id), ) .where( and( eq(hostAccess.hostId, hostId), eq(sharedCredentials.targetUserId, userId), ), ) .limit(1); if (sharedCred.length === 0) { return null; } const cred = sharedCred[0].shared_credentials; if (cred.needsReEncryption) { await this.reEncryptSharedCredential(cred.id, userId); const refreshed = await db .select() .from(sharedCredentials) .where(eq(sharedCredentials.id, cred.id)) .limit(1); if (refreshed.length === 0 || refreshed[0].needsReEncryption) { databaseLogger.warn( "Shared credential needs re-encryption but cannot be accessed yet", { operation: "get_shared_credential_pending", hostId, userId, }, ); return null; } return this.decryptSharedCredential(refreshed[0], userDEK); } return this.decryptSharedCredential(cred, userDEK); } catch (error) { databaseLogger.error("Failed to get shared credential", error, { operation: "get_shared_credential", hostId, userId, }); throw error; } } async updateSharedCredentialsForOriginal( credentialId: number, ownerId: string, ): Promise { try { const sharedCreds = await db .select() .from(sharedCredentials) .where(eq(sharedCredentials.originalCredentialId, credentialId)); const ownerDEK = DataCrypto.getUserDataKey(ownerId); let credentialData: CredentialData; if (ownerDEK) { credentialData = await this.getDecryptedCredential( credentialId, ownerId, ownerDEK, ); } else { try { credentialData = await this.getDecryptedCredentialViaSystemKey(credentialId); } catch (error) { databaseLogger.warn( "Cannot update shared credentials: owner offline and credential not migrated", { operation: "update_shared_credentials_failed", credentialId, ownerId, error: error instanceof Error ? error.message : "Unknown error", }, ); await db .update(sharedCredentials) .set({ needsReEncryption: true }) .where(eq(sharedCredentials.originalCredentialId, credentialId)); return; } } for (const sharedCred of sharedCreds) { const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); if (!targetDEK) { await db .update(sharedCredentials) .set({ needsReEncryption: true }) .where(eq(sharedCredentials.id, sharedCred.id)); continue; } const encryptedForTarget = this.encryptCredentialForUser( credentialData, sharedCred.targetUserId, targetDEK, sharedCred.hostAccessId, ); await db .update(sharedCredentials) .set({ ...encryptedForTarget, needsReEncryption: false, updatedAt: new Date().toISOString(), }) .where(eq(sharedCredentials.id, sharedCred.id)); } } catch (error) { databaseLogger.error("Failed to update shared credentials", error, { operation: "update_shared_credentials", credentialId, }); } } async deleteSharedCredentialsForOriginal( credentialId: number, ): Promise { try { await db .delete(sharedCredentials) .where(eq(sharedCredentials.originalCredentialId, credentialId)); } catch (error) { databaseLogger.error("Failed to delete shared credentials", error, { operation: "delete_shared_credentials", credentialId, }); } } async reEncryptPendingCredentialsForUser(userId: string): Promise { try { const userDEK = DataCrypto.getUserDataKey(userId); if (!userDEK) { return; } const pendingCreds = await db .select() .from(sharedCredentials) .where( and( eq(sharedCredentials.targetUserId, userId), eq(sharedCredentials.needsReEncryption, true), ), ); for (const cred of pendingCreds) { await this.reEncryptSharedCredential(cred.id, userId); } } catch (error) { databaseLogger.error("Failed to re-encrypt pending credentials", error, { operation: "reencrypt_pending_credentials", userId, }); } } private async getDecryptedCredential( credentialId: number, ownerId: string, ownerDEK: Buffer, ): Promise { const creds = await db .select() .from(sshCredentials) .where( and( eq(sshCredentials.id, credentialId), eq(sshCredentials.userId, ownerId), ), ) .limit(1); if (creds.length === 0) { throw new Error(`Credential ${credentialId} not found`); } const cred = creds[0]; return { username: cred.username, authType: cred.authType, password: cred.password ? this.decryptField(cred.password, ownerDEK, credentialId, "password") : undefined, key: cred.key ? this.decryptField(cred.key, ownerDEK, credentialId, "key") : undefined, keyPassword: cred.keyPassword ? this.decryptField( cred.keyPassword, ownerDEK, credentialId, "key_password", ) : undefined, keyType: cred.keyType, }; } private async getDecryptedCredentialViaSystemKey( credentialId: number, ): Promise { const creds = await db .select() .from(sshCredentials) .where(eq(sshCredentials.id, credentialId)) .limit(1); if (creds.length === 0) { throw new Error(`Credential ${credentialId} not found`); } const cred = creds[0]; if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) { throw new Error( "Credential not yet migrated for offline sharing. " + "Please ask credential owner to log in to enable sharing.", ); } const { SystemCrypto } = await import("./system-crypto.js"); const systemCrypto = SystemCrypto.getInstance(); const CSKEK = await systemCrypto.getCredentialSharingKey(); return { username: cred.username, authType: cred.authType, password: cred.systemPassword ? this.decryptField( cred.systemPassword, CSKEK, credentialId, "password", ) : undefined, key: cred.systemKey ? this.decryptField(cred.systemKey, CSKEK, credentialId, "key") : undefined, keyPassword: cred.systemKeyPassword ? this.decryptField( cred.systemKeyPassword, CSKEK, credentialId, "key_password", ) : undefined, keyType: cred.keyType, }; } private encryptCredentialForUser( credentialData: CredentialData, targetUserId: string, targetDEK: Buffer, hostAccessId: number, ): { encryptedUsername: string; encryptedAuthType: string; encryptedPassword: string | null; encryptedKey: string | null; encryptedKeyPassword: string | null; encryptedKeyType: string | null; } { const recordId = `shared-${hostAccessId}-${targetUserId}`; return { encryptedUsername: FieldCrypto.encryptField( credentialData.username, targetDEK, recordId, "username", ), encryptedAuthType: credentialData.authType, encryptedPassword: credentialData.password ? FieldCrypto.encryptField( credentialData.password, targetDEK, recordId, "password", ) : null, encryptedKey: credentialData.key ? FieldCrypto.encryptField( credentialData.key, targetDEK, recordId, "key", ) : null, encryptedKeyPassword: credentialData.keyPassword ? FieldCrypto.encryptField( credentialData.keyPassword, targetDEK, recordId, "key_password", ) : null, encryptedKeyType: credentialData.keyType || null, }; } private decryptSharedCredential( sharedCred: typeof sharedCredentials.$inferSelect, userDEK: Buffer, ): CredentialData { const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`; return { username: FieldCrypto.decryptField( sharedCred.encryptedUsername, userDEK, recordId, "username", ), authType: sharedCred.encryptedAuthType, password: sharedCred.encryptedPassword ? FieldCrypto.decryptField( sharedCred.encryptedPassword, userDEK, recordId, "password", ) : undefined, key: sharedCred.encryptedKey ? FieldCrypto.decryptField( sharedCred.encryptedKey, userDEK, recordId, "key", ) : undefined, keyPassword: sharedCred.encryptedKeyPassword ? FieldCrypto.decryptField( sharedCred.encryptedKeyPassword, userDEK, recordId, "key_password", ) : undefined, keyType: sharedCred.encryptedKeyType || undefined, }; } private decryptField( encryptedValue: string, dek: Buffer, recordId: number | string, fieldName: string, ): string { try { return FieldCrypto.decryptField( encryptedValue, dek, recordId.toString(), fieldName, ); } catch { databaseLogger.warn("Field decryption failed, returning as-is", { operation: "decrypt_field", fieldName, recordId, }); return encryptedValue; } } private async createPendingSharedCredential( hostAccessId: number, originalCredentialId: number, targetUserId: string, ): Promise { await db.insert(sharedCredentials).values({ hostAccessId, originalCredentialId, targetUserId, encryptedUsername: "", encryptedAuthType: "", needsReEncryption: true, }); databaseLogger.info("Created pending shared credential", { operation: "create_pending_shared_credential", hostAccessId, targetUserId, }); } private async reEncryptSharedCredential( sharedCredId: number, userId: string, ): Promise { try { const sharedCred = await db .select() .from(sharedCredentials) .where(eq(sharedCredentials.id, sharedCredId)) .limit(1); if (sharedCred.length === 0) { databaseLogger.warn("Re-encrypt: shared credential not found", { operation: "reencrypt_not_found", sharedCredId, }); return; } const cred = sharedCred[0]; const access = await db .select() .from(hostAccess) .innerJoin(hosts, eq(hostAccess.hostId, hosts.id)) .where(eq(hostAccess.id, cred.hostAccessId)) .limit(1); if (access.length === 0) { databaseLogger.warn("Re-encrypt: host access not found", { operation: "reencrypt_access_not_found", sharedCredId, }); return; } const ownerId = access[0].ssh_data.userId; const userDEK = DataCrypto.getUserDataKey(userId); if (!userDEK) { databaseLogger.warn("Re-encrypt: user DEK not available", { operation: "reencrypt_user_offline", sharedCredId, userId, }); return; } const ownerDEK = DataCrypto.getUserDataKey(ownerId); let credentialData: CredentialData; if (ownerDEK) { credentialData = await this.getDecryptedCredential( cred.originalCredentialId, ownerId, ownerDEK, ); } else { try { credentialData = await this.getDecryptedCredentialViaSystemKey( cred.originalCredentialId, ); } catch (error) { databaseLogger.warn( "Re-encrypt: system key decryption failed, credential may not be migrated yet", { operation: "reencrypt_system_key_failed", sharedCredId, error: error instanceof Error ? error.message : "Unknown error", }, ); return; } } const encryptedForTarget = this.encryptCredentialForUser( credentialData, userId, userDEK, cred.hostAccessId, ); await db .update(sharedCredentials) .set({ ...encryptedForTarget, needsReEncryption: false, updatedAt: new Date().toISOString(), }) .where(eq(sharedCredentials.id, sharedCredId)); } catch (error) { databaseLogger.error("Failed to re-encrypt shared credential", error, { operation: "reencrypt_shared_credential", sharedCredId, userId, }); } } } export { SharedCredentialManager }; ================================================ FILE: src/backend/utils/simple-db-ops.ts ================================================ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js"; import { DataCrypto } from "./data-crypto.js"; import type { SQLiteTable } from "drizzle-orm/sqlite-core"; import type { SQL } from "drizzle-orm"; type TableName = | "users" | "ssh_data" | "ssh_credentials" | "recent_activity" | "socks5_proxy_presets"; class SimpleDBOps { static async insert>( table: SQLiteTable, tableName: TableName, data: T, userId: string, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const tempId = data.id || `temp-${userId}-${Date.now()}`; const dataWithTempId = { ...data, id: tempId }; const encryptedData = DataCrypto.encryptRecord( tableName, dataWithTempId, userId, userDataKey, ); if (tableName === "ssh_credentials") { const { SystemCrypto } = await import("./system-crypto.js"); const systemCrypto = SystemCrypto.getInstance(); const systemKey = await systemCrypto.getCredentialSharingKey(); const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( tableName, dataWithTempId, systemKey, ); Object.assign(encryptedData, systemEncrypted); } if (!data.id) { delete encryptedData.id; } const result = await getDb() .insert(table) .values(encryptedData) .returning(); DatabaseSaveTrigger.triggerSave(`insert_${tableName}`); const decryptedResult = DataCrypto.decryptRecord( tableName, result[0], userId, userDataKey, ); return decryptedResult as T; } static async select>( query: unknown, tableName: TableName, userId: string, ): Promise { const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { return []; } const results = await query; const decryptedResults = DataCrypto.decryptRecords( tableName, results as T[], userId, userDataKey, ); return decryptedResults; } static async selectOne>( query: unknown, tableName: TableName, userId: string, ): Promise { const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { return undefined; } const result = await query; if (!result) return undefined; const decryptedResult = DataCrypto.decryptRecord( tableName, result as T, userId, userDataKey, ); return decryptedResult; } static async update>( table: SQLiteTable, tableName: TableName, where: unknown, data: Partial, userId: string, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const encryptedData = DataCrypto.encryptRecord( tableName, data, userId, userDataKey, ); if (tableName === "ssh_credentials") { const { SystemCrypto } = await import("./system-crypto.js"); const systemCrypto = SystemCrypto.getInstance(); const systemKey = await systemCrypto.getCredentialSharingKey(); const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( tableName, data, systemKey, ); Object.assign(encryptedData, systemEncrypted); } const result = await getDb() .update(table) .set(encryptedData) .where(where as SQL | undefined) .returning(); DatabaseSaveTrigger.triggerSave(`update_${tableName}`); const decryptedResults = DataCrypto.decryptRecords( tableName, result, userId, userDataKey, ); return decryptedResults as T[]; } static async delete( table: SQLiteTable, tableName: TableName, where: unknown, ): Promise { const result = await getDb() .delete(table) .where(where as SQL | undefined) .returning(); DatabaseSaveTrigger.triggerSave(`delete_${tableName}`); return result; } static async healthCheck(userId: string): Promise { return DataCrypto.canUserAccessData(userId); } static isUserDataUnlocked(userId: string): boolean { return DataCrypto.getUserDataKey(userId) !== null; } static async selectEncrypted(query: unknown): Promise { const results = await query; return results as unknown[]; } } export { SimpleDBOps, type TableName }; ================================================ FILE: src/backend/utils/socks5-helper.ts ================================================ export { createSocks5Connection, createProxyConnection, createHttpConnectConnection, createMixedProxyChainConnection, testProxyConnectivity, } from "./proxy-helper.js"; export type { SOCKS5Config } from "./proxy-helper.js"; ================================================ FILE: src/backend/utils/ssh-key-utils.ts ================================================ import ssh2Pkg from "ssh2"; const ssh2Utils = ssh2Pkg.utils; function detectKeyTypeFromContent(keyContent: string): string { const content = keyContent.trim(); if (content.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) { if ( content.includes("ssh-ed25519") || content.includes("AAAAC3NzaC1lZDI1NTE5") ) { return "ssh-ed25519"; } if (content.includes("ssh-rsa") || content.includes("AAAAB3NzaC1yc2E")) { return "ssh-rsa"; } if (content.includes("ecdsa-sha2-nistp256")) { return "ecdsa-sha2-nistp256"; } if (content.includes("ecdsa-sha2-nistp384")) { return "ecdsa-sha2-nistp384"; } if (content.includes("ecdsa-sha2-nistp521")) { return "ecdsa-sha2-nistp521"; } try { const base64Content = content .replace("-----BEGIN OPENSSH PRIVATE KEY-----", "") .replace("-----END OPENSSH PRIVATE KEY-----", "") .replace(/\s/g, ""); const decoded = Buffer.from(base64Content, "base64").toString("binary"); if (decoded.includes("ssh-rsa")) { return "ssh-rsa"; } if (decoded.includes("ssh-ed25519")) { return "ssh-ed25519"; } if (decoded.includes("ecdsa-sha2-nistp256")) { return "ecdsa-sha2-nistp256"; } if (decoded.includes("ecdsa-sha2-nistp384")) { return "ecdsa-sha2-nistp384"; } if (decoded.includes("ecdsa-sha2-nistp521")) { return "ecdsa-sha2-nistp521"; } return "ssh-rsa"; } catch { return "ssh-rsa"; } } if (content.includes("-----BEGIN RSA PRIVATE KEY-----")) { return "ssh-rsa"; } if (content.includes("-----BEGIN DSA PRIVATE KEY-----")) { return "ssh-dss"; } if (content.includes("-----BEGIN EC PRIVATE KEY-----")) { return "ecdsa-sha2-nistp256"; } if (content.includes("-----BEGIN PRIVATE KEY-----")) { try { const base64Content = content .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace(/\s/g, ""); const decoded = Buffer.from(base64Content, "base64"); const decodedString = decoded.toString("binary"); if (decodedString.includes("1.2.840.113549.1.1.1")) { return "ssh-rsa"; } else if (decodedString.includes("1.2.840.10045.2.1")) { if (decodedString.includes("1.2.840.10045.3.1.7")) { return "ecdsa-sha2-nistp256"; } return "ecdsa-sha2-nistp256"; } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } } catch { // expected - base64 decode may fail for some key formats } if (content.length < 800) { return "ssh-ed25519"; } else if (content.length > 1600) { return "ssh-rsa"; } else { return "ecdsa-sha2-nistp256"; } } return "unknown"; } function detectPublicKeyTypeFromContent(publicKeyContent: string): string { const content = publicKeyContent.trim(); if (content.startsWith("ssh-rsa ")) { return "ssh-rsa"; } if (content.startsWith("ssh-ed25519 ")) { return "ssh-ed25519"; } if (content.startsWith("ecdsa-sha2-nistp256 ")) { return "ecdsa-sha2-nistp256"; } if (content.startsWith("ecdsa-sha2-nistp384 ")) { return "ecdsa-sha2-nistp384"; } if (content.startsWith("ecdsa-sha2-nistp521 ")) { return "ecdsa-sha2-nistp521"; } if (content.startsWith("ssh-dss ")) { return "ssh-dss"; } if (content.includes("-----BEGIN PUBLIC KEY-----")) { try { const base64Content = content .replace("-----BEGIN PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "") .replace(/\s/g, ""); const decoded = Buffer.from(base64Content, "base64"); const decodedString = decoded.toString("binary"); if (decodedString.includes("1.2.840.113549.1.1.1")) { return "ssh-rsa"; } else if (decodedString.includes("1.2.840.10045.2.1")) { if (decodedString.includes("1.2.840.10045.3.1.7")) { return "ecdsa-sha2-nistp256"; } return "ecdsa-sha2-nistp256"; } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } } catch { // expected - base64 decode may fail for some key formats } if (content.length < 400) { return "ssh-ed25519"; } else if (content.length > 600) { return "ssh-rsa"; } else { return "ecdsa-sha2-nistp256"; } } if (content.includes("-----BEGIN RSA PUBLIC KEY-----")) { return "ssh-rsa"; } if (content.includes("AAAAB3NzaC1yc2E")) { return "ssh-rsa"; } if (content.includes("AAAAC3NzaC1lZDI1NTE5")) { return "ssh-ed25519"; } if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY")) { return "ecdsa-sha2-nistp256"; } if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHAzODQ")) { return "ecdsa-sha2-nistp384"; } if (content.includes("AAAAE2VjZHNhLXNoYTItbmlzdHA1MjE")) { return "ecdsa-sha2-nistp521"; } if (content.includes("AAAAB3NzaC1kc3M")) { return "ssh-dss"; } return "unknown"; } export interface KeyInfo { privateKey: string; publicKey: string; keyType: string; success: boolean; error?: string; } export interface PublicKeyInfo { publicKey: string; keyType: string; success: boolean; error?: string; } export interface KeyPairValidationResult { isValid: boolean; privateKeyType: string; publicKeyType: string; generatedPublicKey?: string; error?: string; } export function parseSSHKey( privateKeyData: string, passphrase?: string, ): KeyInfo { try { let keyType = "unknown"; let publicKey = ""; let useSSH2 = false; if (ssh2Utils && typeof ssh2Utils.parseKey === "function") { try { const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase); if (!(parsedKey instanceof Error)) { if (parsedKey.type) { keyType = parsedKey.type; } try { const publicKeyBuffer = parsedKey.getPublicSSH(); if (Buffer.isBuffer(publicKeyBuffer)) { const base64Data = publicKeyBuffer.toString("base64"); if (keyType === "ssh-rsa") { publicKey = `ssh-rsa ${base64Data}`; } else if (keyType === "ssh-ed25519") { publicKey = `ssh-ed25519 ${base64Data}`; } else if (keyType.startsWith("ecdsa-")) { publicKey = `${keyType} ${base64Data}`; } else { publicKey = `${keyType} ${base64Data}`; } } else { publicKey = ""; } } catch { publicKey = ""; } useSSH2 = true; } } catch { // expected - ssh2 key parsing may fail } } if (!useSSH2) { keyType = detectKeyTypeFromContent(privateKeyData); publicKey = ""; } return { privateKey: privateKeyData, publicKey, keyType, success: keyType !== "unknown", }; } catch (error) { try { const fallbackKeyType = detectKeyTypeFromContent(privateKeyData); if (fallbackKeyType !== "unknown") { return { privateKey: privateKeyData, publicKey: "", keyType: fallbackKeyType, success: true, }; } } catch { // expected - fallback key type detection may fail } return { privateKey: privateKeyData, publicKey: "", keyType: "unknown", success: false, error: error instanceof Error ? error.message : "Unknown error parsing key", }; } } export function parsePublicKey(publicKeyData: string): PublicKeyInfo { try { const keyType = detectPublicKeyTypeFromContent(publicKeyData); return { publicKey: publicKeyData, keyType, success: keyType !== "unknown", }; } catch (error) { return { publicKey: publicKeyData, keyType: "unknown", success: false, error: error instanceof Error ? error.message : "Unknown error parsing public key", }; } } export function detectKeyType(privateKeyData: string): string { try { const parsedKey = ssh2Utils.parseKey(privateKeyData); if (parsedKey instanceof Error) { return "unknown"; } return parsedKey.type || "unknown"; } catch { return "unknown"; } } export function getFriendlyKeyTypeName(keyType: string): string { const keyTypeMap: Record = { "ssh-rsa": "RSA", "ssh-ed25519": "Ed25519", "ecdsa-sha2-nistp256": "ECDSA P-256", "ecdsa-sha2-nistp384": "ECDSA P-384", "ecdsa-sha2-nistp521": "ECDSA P-521", "ssh-dss": "DSA", "rsa-sha2-256": "RSA-SHA2-256", "rsa-sha2-512": "RSA-SHA2-512", unknown: "Unknown", }; return keyTypeMap[keyType] || keyType; } export function validateKeyPair( privateKeyData: string, publicKeyData: string, passphrase?: string, ): KeyPairValidationResult { try { const privateKeyInfo = parseSSHKey(privateKeyData, passphrase); const publicKeyInfo = parsePublicKey(publicKeyData); if (!privateKeyInfo.success) { return { isValid: false, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, error: `Invalid private key: ${privateKeyInfo.error}`, }; } if (!publicKeyInfo.success) { return { isValid: false, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, error: `Invalid public key: ${publicKeyInfo.error}`, }; } if (privateKeyInfo.keyType !== publicKeyInfo.keyType) { return { isValid: false, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, error: `Key type mismatch: private key is ${privateKeyInfo.keyType}, public key is ${publicKeyInfo.keyType}`, }; } if (privateKeyInfo.publicKey && privateKeyInfo.publicKey.trim()) { const generatedPublicKey = privateKeyInfo.publicKey.trim(); const providedPublicKey = publicKeyData.trim(); const generatedKeyParts = generatedPublicKey.split(" "); const providedKeyParts = providedPublicKey.split(" "); if (generatedKeyParts.length >= 2 && providedKeyParts.length >= 2) { const generatedKeyData = generatedKeyParts[0] + " " + generatedKeyParts[1]; const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1]; if (generatedKeyData === providedKeyData) { return { isValid: true, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, generatedPublicKey: generatedPublicKey, }; } else { return { isValid: false, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, generatedPublicKey: generatedPublicKey, error: "Public key does not match the private key", }; } } } return { isValid: true, privateKeyType: privateKeyInfo.keyType, publicKeyType: publicKeyInfo.keyType, error: "Unable to verify key pair match, but key types are compatible", }; } catch (error) { return { isValid: false, privateKeyType: "unknown", publicKeyType: "unknown", error: error instanceof Error ? error.message : "Unknown error during validation", }; } } ================================================ FILE: src/backend/utils/system-crypto.ts ================================================ import crypto from "crypto"; import { promises as fs } from "fs"; import path from "path"; import { databaseLogger } from "./logger.js"; class SystemCrypto { private static instance: SystemCrypto; private jwtSecret: string | null = null; private databaseKey: Buffer | null = null; private internalAuthToken: string | null = null; private credentialSharingKey: Buffer | null = null; private constructor() {} static getInstance(): SystemCrypto { if (!this.instance) { this.instance = new SystemCrypto(); } return this.instance; } async initializeJWTSecret(): Promise { try { const envSecret = process.env.JWT_SECRET; if (envSecret && envSecret.length >= 64) { this.jwtSecret = envSecret; return; } const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); try { const envContent = await fs.readFile(envPath, "utf8"); const jwtMatch = envContent.match(/^JWT_SECRET=(.+)$/m); if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) { this.jwtSecret = jwtMatch[1]; process.env.JWT_SECRET = jwtMatch[1]; databaseLogger.success("JWT secret loaded from .env file", { operation: "jwt_init_from_file_success", secretLength: jwtMatch[1].length, secretPrefix: jwtMatch[1].substring(0, 8) + "...", }); return; } else { databaseLogger.warn( "JWT_SECRET in .env file is invalid or too short", { operation: "jwt_init_invalid_secret", hasMatch: !!jwtMatch, secretLength: jwtMatch?.[1]?.length || 0, }, ); } } catch { // expected - env file may not exist } await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { operation: "jwt_init_failed", }); throw new Error("JWT secret initialization failed"); } } async getJWTSecret(): Promise { if (!this.jwtSecret) { await this.initializeJWTSecret(); } return this.jwtSecret!; } async initializeDatabaseKey(): Promise { try { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); return; } try { const envContent = await fs.readFile(envPath, "utf8"); const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m); if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; return; } else { // expected - key not found or invalid length in env file } } catch { // expected - env file may not exist } await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { operation: "db_key_init_failed", dataDir: process.env.DATA_DIR || "./db/data", }); throw new Error("Database key initialization failed"); } } async getDatabaseKey(): Promise { if (!this.databaseKey) { await this.initializeDatabaseKey(); } return this.databaseKey!; } async initializeInternalAuthToken(): Promise { try { const envToken = process.env.INTERNAL_AUTH_TOKEN; if (envToken && envToken.length >= 32) { this.internalAuthToken = envToken; return; } const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); try { const envContent = await fs.readFile(envPath, "utf8"); const tokenMatch = envContent.match(/^INTERNAL_AUTH_TOKEN=(.+)$/m); if (tokenMatch && tokenMatch[1] && tokenMatch[1].length >= 32) { this.internalAuthToken = tokenMatch[1]; process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1]; return; } } catch { // expected - env file may not exist } await this.generateAndGuideInternalAuthToken(); } catch (error) { databaseLogger.error("Failed to initialize internal auth token", error, { operation: "internal_auth_init_failed", }); throw new Error("Internal auth token initialization failed"); } } async getInternalAuthToken(): Promise { if (!this.internalAuthToken) { await this.initializeInternalAuthToken(); } return this.internalAuthToken!; } async initializeCredentialSharingKey(): Promise { try { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); const envKey = process.env.CREDENTIAL_SHARING_KEY; if (envKey && envKey.length >= 64) { this.credentialSharingKey = Buffer.from(envKey, "hex"); return; } try { const envContent = await fs.readFile(envPath, "utf8"); const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m); if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) { this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex"); process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1]; return; } } catch { // expected - env file may not exist } await this.generateAndGuideCredentialSharingKey(); } catch (error) { databaseLogger.error( "Failed to initialize credential sharing key", error, { operation: "cred_sharing_key_init_failed", dataDir: process.env.DATA_DIR || "./db/data", }, ); throw new Error("Credential sharing key initialization failed"); } } async getCredentialSharingKey(): Promise { if (!this.credentialSharingKey) { await this.initializeCredentialSharingKey(); } return this.credentialSharingKey!; } private async generateAndGuideUser(): Promise { const newSecret = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); this.jwtSecret = newSecret; await this.updateEnvFile("JWT_SECRET", newSecret); databaseLogger.success("JWT secret auto-generated and saved to .env", { operation: "jwt_auto_generated", instanceId, envVarName: "JWT_SECRET", note: "Ready for use - no restart required", }); } private async generateAndGuideDatabaseKey(): Promise { const newKey = crypto.randomBytes(32); const newKeyHex = newKey.toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); this.databaseKey = newKey; await this.updateEnvFile("DATABASE_KEY", newKeyHex); databaseLogger.success("Database key auto-generated and saved to .env", { operation: "db_key_auto_generated", instanceId, envVarName: "DATABASE_KEY", note: "Ready for use - no restart required", }); } private async generateAndGuideInternalAuthToken(): Promise { const newToken = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); this.internalAuthToken = newToken; await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken); databaseLogger.success( "Internal auth token auto-generated and saved to .env", { operation: "internal_auth_auto_generated", instanceId, envVarName: "INTERNAL_AUTH_TOKEN", note: "Ready for use - no restart required", }, ); } private async generateAndGuideCredentialSharingKey(): Promise { const newKey = crypto.randomBytes(32); const newKeyHex = newKey.toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); this.credentialSharingKey = newKey; await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex); databaseLogger.success( "Credential sharing key auto-generated and saved to .env", { operation: "cred_sharing_key_auto_generated", instanceId, envVarName: "CREDENTIAL_SHARING_KEY", note: "Used for offline credential sharing - no restart required", }, ); } async validateJWTSecret(): Promise { try { const secret = await this.getJWTSecret(); if (!secret || secret.length < 32) { return false; } const jwt = await import("jsonwebtoken"); const testPayload = { test: true, timestamp: Date.now() }; const token = jwt.default.sign(testPayload, secret, { expiresIn: "1s" }); const decoded = jwt.default.verify(token, secret); return !!decoded; } catch (error) { databaseLogger.error("JWT secret validation failed", error, { operation: "jwt_validation_failed", }); return false; } } async getSystemKeyStatus() { const isValid = await this.validateJWTSecret(); const hasSecret = this.jwtSecret !== null; const hasEnvVar = !!( process.env.JWT_SECRET && process.env.JWT_SECRET.length >= 64 ); return { hasSecret, isValid, storage: { environment: hasEnvVar, }, algorithm: "HS256", note: "Using simplified key management without encryption layers", }; } private async updateEnvFile(key: string, value: string): Promise { const dataDir = process.env.DATA_DIR || "./db/data"; const envPath = path.join(dataDir, ".env"); try { await fs.mkdir(dataDir, { recursive: true }); let envContent = ""; try { envContent = await fs.readFile(envPath, "utf8"); } catch { envContent = "# Termix Auto-generated Configuration\n\n"; } const keyRegex = new RegExp(`^${key}=.*$`, "m"); if (keyRegex.test(envContent)) { envContent = envContent.replace(keyRegex, `${key}=${value}`); } else { if (!envContent.includes("# Security Keys")) { envContent += "\n# Security Keys (Auto-generated)\n"; } envContent += `${key}=${value}\n`; } await fs.writeFile(envPath, envContent); process.env[key] = value; } catch (error) { databaseLogger.error(`Failed to update .env file with ${key}`, error, { operation: "env_file_update_failed", key, }); throw error; } } } export { SystemCrypto }; ================================================ FILE: src/backend/utils/user-agent-parser.ts ================================================ import type { Request } from "express"; import crypto from "crypto"; export type DeviceType = "web" | "desktop" | "mobile"; export interface DeviceInfo { type: DeviceType; browser: string; version: string; os: string; deviceInfo: string; } export function detectPlatform(req: Request): DeviceType { const userAgent = req.headers["user-agent"] || ""; const electronHeader = req.headers["x-electron-app"]; if (electronHeader === "true" || userAgent.includes("Termix-Desktop")) { return "desktop"; } if (userAgent.includes("Termix-Mobile")) { return "mobile"; } const isDesktopOS = userAgent.includes("Windows") || userAgent.includes("Macintosh") || userAgent.includes("Mac OS X") || userAgent.includes("X11") || userAgent.includes("Linux x86_64"); if ( (userAgent.includes("Android") && !isDesktopOS) || userAgent.includes("iPhone") || userAgent.includes("iPad") ) { return "mobile"; } return "web"; } export function parseUserAgent(req: Request): DeviceInfo { const userAgent = req.headers["user-agent"] || "Unknown"; const platform = detectPlatform(req); if (platform === "desktop") { return parseElectronUserAgent(userAgent); } if (platform === "mobile") { return parseMobileUserAgent(userAgent); } return parseWebUserAgent(userAgent); } function parseElectronUserAgent(userAgent: string): DeviceInfo { let os = "Unknown OS"; let version = "Unknown"; const termixMatch = userAgent.match(/Termix-Desktop\/([\d.]+)\s*\(([^;)]+)/); if (termixMatch) { version = termixMatch[1]; os = termixMatch[2].trim(); } else { if (userAgent.includes("Windows")) { os = parseWindowsVersion(userAgent); } else if (userAgent.includes("Mac OS X")) { os = parseMacVersion(userAgent); } else if (userAgent.includes("macOS")) { os = "macOS"; } else if (userAgent.includes("Linux")) { os = "Linux"; } const electronMatch = userAgent.match(/Electron\/([\d.]+)/); if (electronMatch) { version = electronMatch[1]; } } return { type: "desktop", browser: "Termix Desktop", version, os, deviceInfo: `Termix Desktop on ${os}`, }; } function parseMobileUserAgent(userAgent: string): DeviceInfo { let os = "Unknown OS"; let version = "Unknown"; const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i); if (termixPlatformMatch) { const platform = termixPlatformMatch[1]; if (platform.toLowerCase() === "android") { const androidMatch = userAgent.match(/Android ([\d.]+)/); os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; } else if (platform.toLowerCase() === "ios") { const iosMatch = userAgent.match(/OS ([\d_]+)/); if (iosMatch) { const iosVersion = iosMatch[1].replace(/_/g, "."); os = `iOS ${iosVersion}`; } else { os = "iOS"; } } } else { if (userAgent.includes("Android")) { const androidMatch = userAgent.match(/Android ([\d.]+)/); os = androidMatch ? `Android ${androidMatch[1]}` : "Android"; } else if ( userAgent.includes("iOS") || userAgent.includes("iPhone") || userAgent.includes("iPad") ) { const iosMatch = userAgent.match(/OS ([\d_]+)/); if (iosMatch) { const iosVersion = iosMatch[1].replace(/_/g, "."); os = `iOS ${iosVersion}`; } else { os = "iOS"; } } } const versionMatch = userAgent.match( /Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i, ); if (versionMatch) { version = versionMatch[1]; } return { type: "mobile", browser: "Termix Mobile", version, os, deviceInfo: `Termix Mobile on ${os}`, }; } function parseWebUserAgent(userAgent: string): DeviceInfo { let browser = "Unknown Browser"; let version = "Unknown"; let os = "Unknown OS"; if (userAgent.includes("Edg/")) { const match = userAgent.match(/Edg\/([\d.]+)/); browser = "Edge"; version = match ? match[1] : "Unknown"; } else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) { const match = userAgent.match(/Chrome\/([\d.]+)/); browser = "Chrome"; version = match ? match[1] : "Unknown"; } else if (userAgent.includes("Firefox/")) { const match = userAgent.match(/Firefox\/([\d.]+)/); browser = "Firefox"; version = match ? match[1] : "Unknown"; } else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) { const match = userAgent.match(/Version\/([\d.]+)/); browser = "Safari"; version = match ? match[1] : "Unknown"; } else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) { const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/); browser = "Opera"; version = match ? match[1] : "Unknown"; } if (userAgent.includes("Windows")) { os = parseWindowsVersion(userAgent); } else if (userAgent.includes("Android")) { const match = userAgent.match(/Android ([\d.]+)/); os = match ? `Android ${match[1]}` : "Android"; } else if ( userAgent.includes("iOS") || userAgent.includes("iPhone") || userAgent.includes("iPad") ) { const match = userAgent.match(/OS ([\d_]+)/); if (match) { const iosVersion = match[1].replace(/_/g, "."); os = `iOS ${iosVersion}`; } else { os = "iOS"; } } else if (userAgent.includes("Mac OS X")) { os = parseMacVersion(userAgent); } else if (userAgent.includes("Linux")) { os = "Linux"; } if (version !== "Unknown") { const versionParts = version.split("."); version = versionParts.slice(0, 2).join("."); } return { type: "web", browser, version, os, deviceInfo: `${browser} ${version} on ${os}`, }; } function parseWindowsVersion(userAgent: string): string { if (userAgent.includes("Windows NT 10.0")) { return "Windows 10/11"; } else if (userAgent.includes("Windows NT 6.3")) { return "Windows 8.1"; } else if (userAgent.includes("Windows NT 6.2")) { return "Windows 8"; } else if (userAgent.includes("Windows NT 6.1")) { return "Windows 7"; } else if (userAgent.includes("Windows NT 6.0")) { return "Windows Vista"; } else if ( userAgent.includes("Windows NT 5.1") || userAgent.includes("Windows NT 5.2") ) { return "Windows XP"; } return "Windows"; } function parseMacVersion(userAgent: string): string { const match = userAgent.match(/Mac OS X ([\d_]+)/); if (match) { const version = match[1].replace(/_/g, "."); const parts = version.split("."); const major = parseInt(parts[0]); const minor = parseInt(parts[1]); if (major === 10) { if (minor >= 15) return `macOS ${major}.${minor}`; if (minor === 14) return "macOS Mojave"; if (minor === 13) return "macOS High Sierra"; if (minor === 12) return "macOS Sierra"; } else if (major >= 11) { return `macOS ${major}`; } return `macOS ${version}`; } return "macOS"; } /** * Generate a stable device fingerprint based on device type, browser, and OS. * Ignores minor version numbers to handle browser auto-updates. */ export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string { let fingerprintString = ""; if (deviceInfo.type === "desktop") { fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; } else if (deviceInfo.type === "mobile") { fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; } else { const browserMajor = deviceInfo.version.split(".")[0]; fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`; } return crypto.createHash("sha256").update(fingerprintString).digest("hex"); } ================================================ FILE: src/backend/utils/user-crypto.ts ================================================ import crypto from "crypto"; import { getDb } from "../database/db/index.js"; import { settings } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { databaseLogger } from "./logger.js"; interface KEKSalt { salt: string; iterations: number; algorithm: string; createdAt: string; } interface EncryptedDEK { data: string; iv: string; tag: string; algorithm: string; createdAt: string; } interface UserSession { dataKey: Buffer; expiresAt: number; lastActivity?: number; } class UserCrypto { private static instance: UserCrypto; private userSessions: Map = new Map(); private sessionExpiredCallback?: (userId: string) => void; private static readonly PBKDF2_ITERATIONS = 100000; private static readonly KEK_LENGTH = 32; private static readonly DEK_LENGTH = 32; private constructor() { setInterval( () => { this.cleanupExpiredSessions(); }, 5 * 60 * 1000, ); } static getInstance(): UserCrypto { if (!this.instance) { this.instance = new UserCrypto(); } return this.instance; } setSessionExpiredCallback(callback: (userId: string) => void): void { this.sessionExpiredCallback = callback; } async setupUserEncryption(userId: string, password: string): Promise { const kekSalt = await this.generateKEKSalt(); await this.storeKEKSalt(userId, kekSalt); const KEK = this.deriveKEK(password, kekSalt); const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); const encryptedDEK = this.encryptDEK(DEK, KEK); await this.storeEncryptedDEK(userId, encryptedDEK); KEK.fill(0); DEK.fill(0); } async setupOIDCUserEncryption( userId: string, sessionDurationMs: number, ): Promise { const existingEncryptedDEK = await this.getEncryptedDEK(userId); let DEK: Buffer; if (existingEncryptedDEK) { const systemKey = this.deriveOIDCSystemKey(userId); DEK = this.decryptDEK(existingEncryptedDEK, systemKey); systemKey.fill(0); } else { DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH); const systemKey = this.deriveOIDCSystemKey(userId); try { const encryptedDEK = this.encryptDEK(DEK, systemKey); await this.storeEncryptedDEK(userId, encryptedDEK); const storedEncryptedDEK = await this.getEncryptedDEK(userId); if ( storedEncryptedDEK && storedEncryptedDEK.data !== encryptedDEK.data ) { DEK.fill(0); DEK = this.decryptDEK(storedEncryptedDEK, systemKey); } else if (!storedEncryptedDEK) { throw new Error("Failed to store and retrieve user encryption key."); } } finally { systemKey.fill(0); } } const now = Date.now(); this.userSessions.set(userId, { dataKey: Buffer.from(DEK), expiresAt: now + sessionDurationMs, }); DEK.fill(0); } async authenticateUser( userId: string, password: string, sessionDurationMs: number, ): Promise { try { const kekSalt = await this.getKEKSalt(userId); if (!kekSalt) return false; const KEK = this.deriveKEK(password, kekSalt); const encryptedDEK = await this.getEncryptedDEK(userId); if (!encryptedDEK) { KEK.fill(0); return false; } const DEK = this.decryptDEK(encryptedDEK, KEK); KEK.fill(0); if (!DEK || DEK.length === 0) { databaseLogger.error("DEK is empty or invalid after decryption", { operation: "user_crypto_auth_debug", userId, dekLength: DEK ? DEK.length : 0, }); return false; } const now = Date.now(); const oldSession = this.userSessions.get(userId); if (oldSession) { oldSession.dataKey.fill(0); } this.userSessions.set(userId, { dataKey: Buffer.from(DEK), expiresAt: now + sessionDurationMs, }); DEK.fill(0); return true; } catch (error) { databaseLogger.warn("User authentication failed", { operation: "user_crypto_auth_failed", userId, error: error instanceof Error ? error.message : "Unknown", }); return false; } } async authenticateOIDCUser( userId: string, sessionDurationMs: number, ): Promise { try { const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId); if (oidcEncryptedDEK) { const systemKey = this.deriveOIDCSystemKey(userId); const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey); systemKey.fill(0); if (!DEK || DEK.length === 0) { databaseLogger.error( "Failed to decrypt OIDC DEK for dual-auth user", { operation: "oidc_auth_dual_decrypt_failed", userId, }, ); return false; } const now = Date.now(); const oldSession = this.userSessions.get(userId); if (oldSession) { oldSession.dataKey.fill(0); } this.userSessions.set(userId, { dataKey: Buffer.from(DEK), expiresAt: now + sessionDurationMs, }); DEK.fill(0); return true; } const kekSalt = await this.getKEKSalt(userId); const encryptedDEK = await this.getEncryptedDEK(userId); if (!kekSalt || !encryptedDEK) { await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } const systemKey = this.deriveOIDCSystemKey(userId); const DEK = this.decryptDEK(encryptedDEK, systemKey); systemKey.fill(0); if (!DEK || DEK.length === 0) { await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } const now = Date.now(); const oldSession = this.userSessions.get(userId); if (oldSession) { oldSession.dataKey.fill(0); } this.userSessions.set(userId, { dataKey: Buffer.from(DEK), expiresAt: now + sessionDurationMs, }); DEK.fill(0); return true; } catch (error) { databaseLogger.error("OIDC authentication failed", error, { operation: "oidc_auth_error", userId, error: error instanceof Error ? error.message : "Unknown", }); await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } } getUserDataKey(userId: string): Buffer | null { const session = this.userSessions.get(userId); if (!session) { return null; } const now = Date.now(); if (now > session.expiresAt) { this.userSessions.delete(userId); session.dataKey.fill(0); if (this.sessionExpiredCallback) { this.sessionExpiredCallback(userId); } return null; } return session.dataKey; } logoutUser(userId: string): void { const session = this.userSessions.get(userId); if (session) { session.dataKey.fill(0); this.userSessions.delete(userId); } } isUserUnlocked(userId: string): boolean { return this.getUserDataKey(userId) !== null; } async changeUserPassword( userId: string, oldPassword: string, newPassword: string, ): Promise { try { const isValid = await this.validatePassword(userId, oldPassword); if (!isValid) return false; const kekSalt = await this.getKEKSalt(userId); if (!kekSalt) return false; const oldKEK = this.deriveKEK(oldPassword, kekSalt); const encryptedDEK = await this.getEncryptedDEK(userId); if (!encryptedDEK) return false; const DEK = this.decryptDEK(encryptedDEK, oldKEK); const newKekSalt = await this.generateKEKSalt(); const newKEK = this.deriveKEK(newPassword, newKekSalt); const newEncryptedDEK = this.encryptDEK(DEK, newKEK); await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); oldKEK.fill(0); newKEK.fill(0); DEK.fill(0); return true; } catch (error) { databaseLogger.error("Password change failed", error, { operation: "password_change_error", userId, error: error instanceof Error ? error.message : "Unknown error", }); return false; } } async resetUserPasswordWithPreservedDEK( userId: string, newPassword: string, ): Promise { try { const existingDEK = this.getUserDataKey(userId); if (!existingDEK) { return false; } const newKekSalt = await this.generateKEKSalt(); const newKEK = this.deriveKEK(newPassword, newKekSalt); const newEncryptedDEK = this.encryptDEK(existingDEK, newKEK); await this.storeKEKSalt(userId, newKekSalt); await this.storeEncryptedDEK(userId, newEncryptedDEK); const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); newKEK.fill(0); const session = this.userSessions.get(userId); if (session) { session.lastActivity = Date.now(); } return true; } catch (error) { databaseLogger.error("Password reset with preserved DEK failed", error, { operation: "password_reset_preserve_error", userId, error: error instanceof Error ? error.message : "Unknown error", }); return false; } } async convertToOIDCEncryption(userId: string): Promise { try { const existingEncryptedDEK = await this.getEncryptedDEK(userId); const existingKEKSalt = await this.getKEKSalt(userId); if (!existingEncryptedDEK && !existingKEKSalt) { databaseLogger.warn("No existing encryption to convert for user", { operation: "convert_to_oidc_encryption_skip", userId, }); return; } const existingDEK = this.getUserDataKey(userId); if (!existingDEK) { throw new Error( "Cannot convert to OIDC encryption - user session not active. Please log in with password first.", ); } const systemKey = this.deriveOIDCSystemKey(userId); const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); systemKey.fill(0); const key = `user_encrypted_dek_oidc_${userId}`; const value = JSON.stringify(oidcEncryptedDEK); const { getDb } = await import("../database/db/index.js"); const { settings } = await import("../database/db/schema.js"); const { eq } = await import("drizzle-orm"); 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 }); } databaseLogger.info( "Converted user encryption to dual-auth (password + OIDC)", { operation: "convert_to_oidc_encryption_preserved", userId, }, ); const { saveMemoryDatabaseToFile } = await import("../database/db/index.js"); await saveMemoryDatabaseToFile(); } catch (error) { databaseLogger.error("Failed to convert to OIDC encryption", error, { operation: "convert_to_oidc_encryption_error", userId, error: error instanceof Error ? error.message : "Unknown error", }); throw error; } } private async validatePassword( userId: string, password: string, ): Promise { try { const kekSalt = await this.getKEKSalt(userId); if (!kekSalt) return false; const KEK = this.deriveKEK(password, kekSalt); const encryptedDEK = await this.getEncryptedDEK(userId); if (!encryptedDEK) return false; const DEK = this.decryptDEK(encryptedDEK, KEK); KEK.fill(0); DEK.fill(0); return true; } catch { return false; } } private cleanupExpiredSessions(): void { const now = Date.now(); const expiredUsers: string[] = []; for (const [userId, session] of this.userSessions.entries()) { if (now > session.expiresAt) { session.dataKey.fill(0); expiredUsers.push(userId); } } expiredUsers.forEach((userId) => { this.userSessions.delete(userId); }); } private async generateKEKSalt(): Promise { return { salt: crypto.randomBytes(32).toString("hex"), iterations: UserCrypto.PBKDF2_ITERATIONS, algorithm: "pbkdf2-sha256", createdAt: new Date().toISOString(), }; } private deriveKEK(password: string, kekSalt: KEKSalt): Buffer { return crypto.pbkdf2Sync( password, Buffer.from(kekSalt.salt, "hex"), kekSalt.iterations, UserCrypto.KEK_LENGTH, "sha256", ); } private deriveOIDCSystemKey(userId: string): Buffer { const systemSecret = process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default"; const salt = Buffer.from(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); let encrypted = cipher.update(dek); encrypted = Buffer.concat([encrypted, cipher.final()]); const tag = cipher.getAuthTag(); return { data: encrypted.toString("hex"), iv: iv.toString("hex"), tag: tag.toString("hex"), algorithm: "aes-256-gcm", createdAt: new Date().toISOString(), }; } private decryptDEK(encryptedDEK: EncryptedDEK, kek: Buffer): Buffer { const decipher = crypto.createDecipheriv( "aes-256-gcm", kek, Buffer.from(encryptedDEK.iv, "hex"), ); decipher.setAuthTag(Buffer.from(encryptedDEK.tag, "hex")); let decrypted = decipher.update(Buffer.from(encryptedDEK.data, "hex")); decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted; } private async storeKEKSalt(userId: string, kekSalt: KEKSalt): Promise { const key = `user_kek_salt_${userId}`; const value = JSON.stringify(kekSalt); 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 getKEKSalt(userId: string): Promise { try { const key = `user_kek_salt_${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; } } private async storeEncryptedDEK( userId: string, encryptedDEK: EncryptedDEK, ): Promise { const key = `user_encrypted_dek_${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 getEncryptedDEK(userId: string): Promise { try { const key = `user_encrypted_dek_${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; } } private async getOIDCEncryptedDEK( userId: string, ): Promise { try { const key = `user_encrypted_dek_oidc_${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 }; ================================================ FILE: src/backend/utils/user-data-export.ts ================================================ import { getDb } from "../database/db/index.js"; import { users, hosts, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, } from "../database/db/schema.js"; import { eq } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { databaseLogger } from "./logger.js"; interface UserExportData { version: string; exportedAt: string; userId: string; username: string; userData: { sshHosts: unknown[]; sshCredentials: unknown[]; fileManagerData: { recent: unknown[]; pinned: unknown[]; shortcuts: unknown[]; }; dismissedAlerts: unknown[]; }; metadata: { totalRecords: number; encrypted: boolean; exportType: "user_data" | "system_config" | "all"; }; } class UserDataExport { private static readonly EXPORT_VERSION = "v2.0"; static async exportUserData( userId: string, options: { format?: "encrypted" | "plaintext"; scope?: "user_data" | "all"; includeCredentials?: boolean; } = {}, ): Promise { const { format = "encrypted", scope = "user_data", includeCredentials = true, } = options; try { const user = await getDb() .select() .from(users) .where(eq(users.id, userId)); if (!user || user.length === 0) { throw new Error(`User not found: ${userId}`); } const userRecord = user[0]; let userDataKey: Buffer | null = null; if (format === "plaintext") { userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) { throw new Error( "User data not unlocked - password required for plaintext export", ); } } const sshHosts = await getDb() .select() .from(hosts) .where(eq(hosts.userId, userId)); const processedSshHosts = format === "plaintext" && userDataKey ? sshHosts.map((host) => DataCrypto.decryptRecord("ssh_data", host, userId, userDataKey!), ) : sshHosts; let sshCredentialsData: unknown[] = []; if (includeCredentials) { const credentials = await getDb() .select() .from(sshCredentials) .where(eq(sshCredentials.userId, userId)); sshCredentialsData = format === "plaintext" && userDataKey ? credentials.map((cred) => DataCrypto.decryptRecord( "ssh_credentials", cred, userId, userDataKey!, ), ) : credentials; } const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([ getDb() .select() .from(fileManagerRecent) .where(eq(fileManagerRecent.userId, userId)), getDb() .select() .from(fileManagerPinned) .where(eq(fileManagerPinned.userId, userId)), getDb() .select() .from(fileManagerShortcuts) .where(eq(fileManagerShortcuts.userId, userId)), ]); const alerts = await getDb() .select() .from(dismissedAlerts) .where(eq(dismissedAlerts.userId, userId)); const exportData: UserExportData = { version: this.EXPORT_VERSION, exportedAt: new Date().toISOString(), userId: userRecord.id, username: userRecord.username, userData: { sshHosts: processedSshHosts, sshCredentials: sshCredentialsData, fileManagerData: { recent: recentFiles, pinned: pinnedFiles, shortcuts: shortcuts, }, dismissedAlerts: alerts, }, metadata: { totalRecords: processedSshHosts.length + sshCredentialsData.length + recentFiles.length + pinnedFiles.length + shortcuts.length + alerts.length, encrypted: format === "encrypted", exportType: scope, }, }; databaseLogger.success("User data export completed", { operation: "user_data_export_complete", userId, totalRecords: exportData.metadata.totalRecords, format, sshHosts: processedSshHosts.length, sshCredentials: sshCredentialsData.length, }); return exportData; } catch (error) { databaseLogger.error("User data export failed", error, { operation: "user_data_export_failed", userId, format, scope, }); throw error; } } static async exportUserDataToJSON( userId: string, options: { format?: "encrypted" | "plaintext"; scope?: "user_data" | "all"; includeCredentials?: boolean; pretty?: boolean; } = {}, ): Promise { const { pretty = true } = options; const exportData = await this.exportUserData(userId, options); return JSON.stringify(exportData, null, pretty ? 2 : 0); } static validateExportData(data: unknown): { valid: boolean; errors: string[]; } { const errors: string[] = []; if (!data || typeof data !== "object") { errors.push("Export data must be an object"); return { valid: false, errors }; } const dataObj = data as Record; if (!dataObj.version) { errors.push("Missing version field"); } if (!dataObj.userId) { errors.push("Missing userId field"); } if (!dataObj.userData || typeof dataObj.userData !== "object") { errors.push("Missing or invalid userData field"); } if (!dataObj.metadata || typeof dataObj.metadata !== "object") { errors.push("Missing or invalid metadata field"); } if (dataObj.userData) { const userData = dataObj.userData as Record; const requiredFields = [ "sshHosts", "sshCredentials", "fileManagerData", "dismissedAlerts", ]; for (const field of requiredFields) { if ( !Array.isArray(userData[field]) && !(field === "fileManagerData" && typeof userData[field] === "object") ) { errors.push(`Missing or invalid userData.${field} field`); } } if ( userData.fileManagerData && typeof userData.fileManagerData === "object" ) { const fileManagerData = userData.fileManagerData as Record< string, unknown >; const fmFields = ["recent", "pinned", "shortcuts"]; for (const field of fmFields) { if (!Array.isArray(fileManagerData[field])) { errors.push( `Missing or invalid userData.fileManagerData.${field} field`, ); } } } } return { valid: errors.length === 0, errors }; } static getExportStats(data: UserExportData): { version: string; exportedAt: string; username: string; totalRecords: number; breakdown: { sshHosts: number; sshCredentials: number; fileManagerItems: number; dismissedAlerts: number; }; encrypted: boolean; } { return { version: data.version, exportedAt: data.exportedAt, username: data.username, totalRecords: data.metadata.totalRecords, breakdown: { sshHosts: data.userData.sshHosts.length, sshCredentials: data.userData.sshCredentials.length, fileManagerItems: data.userData.fileManagerData.recent.length + data.userData.fileManagerData.pinned.length + data.userData.fileManagerData.shortcuts.length, dismissedAlerts: data.userData.dismissedAlerts.length, }, encrypted: data.metadata.encrypted, }; } } export { UserDataExport, type UserExportData }; ================================================ FILE: src/backend/utils/user-data-import.ts ================================================ import { getDb } from "../database/db/index.js"; import { users, hosts, sshCredentials, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts, } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { UserDataExport, type UserExportData } from "./user-data-export.js"; import { databaseLogger } from "./logger.js"; interface ImportOptions { replaceExisting?: boolean; skipCredentials?: boolean; skipFileManagerData?: boolean; dryRun?: boolean; } interface ImportResult { success: boolean; summary: { sshHostsImported: number; sshCredentialsImported: number; fileManagerItemsImported: number; dismissedAlertsImported: number; skippedItems: number; errors: string[]; }; dryRun: boolean; } class UserDataImport { static async importUserData( targetUserId: string, exportData: UserExportData, options: ImportOptions = {}, ): Promise { const { replaceExisting = false, skipCredentials = false, skipFileManagerData = false, dryRun = false, } = options; try { const targetUser = await getDb() .select() .from(users) .where(eq(users.id, targetUserId)); if (!targetUser || targetUser.length === 0) { throw new Error(`Target user not found: ${targetUserId}`); } const validation = UserDataExport.validateExportData(exportData); if (!validation.valid) { throw new Error(`Invalid export data: ${validation.errors.join(", ")}`); } let userDataKey: Buffer | null = null; if (exportData.metadata.encrypted) { userDataKey = DataCrypto.getUserDataKey(targetUserId); if (!userDataKey) { throw new Error( "Target user data not unlocked - password required for encrypted import", ); } } const result: ImportResult = { success: false, summary: { sshHostsImported: 0, sshCredentialsImported: 0, fileManagerItemsImported: 0, dismissedAlertsImported: 0, skippedItems: 0, errors: [], }, dryRun, }; if ( exportData.userData.sshHosts && exportData.userData.sshHosts.length > 0 ) { const importStats = await this.importSshHosts( targetUserId, exportData.userData.sshHosts as Record[], { replaceExisting, dryRun, userDataKey }, ); result.summary.sshHostsImported = importStats.imported; result.summary.skippedItems += importStats.skipped; result.summary.errors.push(...importStats.errors); } if ( !skipCredentials && exportData.userData.sshCredentials && exportData.userData.sshCredentials.length > 0 ) { const importStats = await this.importSshCredentials( targetUserId, exportData.userData.sshCredentials as Record[], { replaceExisting, dryRun, userDataKey }, ); result.summary.sshCredentialsImported = importStats.imported; result.summary.skippedItems += importStats.skipped; result.summary.errors.push(...importStats.errors); } if (!skipFileManagerData && exportData.userData.fileManagerData) { const importStats = await this.importFileManagerData( targetUserId, exportData.userData.fileManagerData, { replaceExisting, dryRun }, ); result.summary.fileManagerItemsImported = importStats.imported; result.summary.skippedItems += importStats.skipped; result.summary.errors.push(...importStats.errors); } if ( exportData.userData.dismissedAlerts && exportData.userData.dismissedAlerts.length > 0 ) { const importStats = await this.importDismissedAlerts( targetUserId, exportData.userData.dismissedAlerts as Record[], { replaceExisting, dryRun }, ); result.summary.dismissedAlertsImported = importStats.imported; result.summary.skippedItems += importStats.skipped; result.summary.errors.push(...importStats.errors); } result.success = result.summary.errors.length === 0; databaseLogger.success("User data import completed", { operation: "user_data_import_complete", targetUserId, dryRun, ...result.summary, }); return result; } catch (error) { databaseLogger.error("User data import failed", error, { operation: "user_data_import_failed", targetUserId, dryRun, }); throw error; } } private static async importSshHosts( targetUserId: string, sshHosts: Record[], options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null; }, ) { let imported = 0; let skipped = 0; const errors: string[] = []; for (const host of sshHosts) { try { if (options.dryRun) { imported++; continue; } const existing = await getDb() .select() .from(hosts) .where( and( eq(hosts.userId, targetUserId), eq(hosts.ip, host.ip as string), eq(hosts.port, host.port as number), eq(hosts.username, host.username as string), ), ); if (existing.length > 0 && !options.replaceExisting) { skipped++; continue; } const newHostData: Record = { ...host, userId: targetUserId, updatedAt: new Date().toISOString(), }; if (existing.length === 0) { newHostData.createdAt = new Date().toISOString(); } let processedHostData: Record = newHostData; if (options.userDataKey) { processedHostData = DataCrypto.encryptRecord( "ssh_data", newHostData, targetUserId, options.userDataKey, ) as Record; } delete processedHostData.id; if (existing.length > 0 && options.replaceExisting) { await getDb() .update(hosts) .set(processedHostData as unknown as typeof hosts.$inferInsert) .where(eq(hosts.id, existing[0].id)); } else { await getDb() .insert(hosts) .values(processedHostData as unknown as typeof hosts.$inferInsert); } imported++; } catch (error) { errors.push( `SSH host import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } return { imported, skipped, errors }; } private static async importSshCredentials( targetUserId: string, credentials: Record[], options: { replaceExisting: boolean; dryRun: boolean; userDataKey: Buffer | null; }, ) { let imported = 0; let skipped = 0; const errors: string[] = []; for (const credential of credentials) { try { if (options.dryRun) { imported++; continue; } const existing = await getDb() .select() .from(sshCredentials) .where( and( eq(sshCredentials.userId, targetUserId), eq(sshCredentials.name, credential.name as string), ), ); if (existing.length > 0 && !options.replaceExisting) { skipped++; continue; } const newCredentialData: Record = { ...credential, userId: targetUserId, updatedAt: new Date().toISOString(), }; if (existing.length === 0) { newCredentialData.usageCount = 0; newCredentialData.lastUsed = null; newCredentialData.createdAt = new Date().toISOString(); } let processedCredentialData: Record = newCredentialData; if (options.userDataKey) { processedCredentialData = DataCrypto.encryptRecord( "ssh_credentials", newCredentialData, targetUserId, options.userDataKey, ) as Record; } delete processedCredentialData.id; if (existing.length > 0 && options.replaceExisting) { await getDb() .update(sshCredentials) .set( processedCredentialData as unknown as typeof sshCredentials.$inferInsert, ) .where(eq(sshCredentials.id, existing[0].id)); } else { await getDb() .insert(sshCredentials) .values( processedCredentialData as unknown as typeof sshCredentials.$inferInsert, ); } imported++; } catch (error) { errors.push( `SSH credential import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } return { imported, skipped, errors }; } private static async importFileManagerData( targetUserId: string, fileManagerData: Record, options: { replaceExisting: boolean; dryRun: boolean }, ) { let imported = 0; let skipped = 0; const errors: string[] = []; try { if (fileManagerData.recent && Array.isArray(fileManagerData.recent)) { for (const item of fileManagerData.recent) { try { if (!options.dryRun) { const newItem = { ...item, id: undefined, userId: targetUserId, lastOpened: new Date().toISOString(), }; await getDb().insert(fileManagerRecent).values(newItem); } imported++; } catch (error) { errors.push( `Recent file import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } } if (fileManagerData.pinned && Array.isArray(fileManagerData.pinned)) { for (const item of fileManagerData.pinned) { try { if (!options.dryRun) { const newItem = { ...item, id: undefined, userId: targetUserId, pinnedAt: new Date().toISOString(), }; await getDb().insert(fileManagerPinned).values(newItem); } imported++; } catch (error) { errors.push( `Pinned file import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } } if ( fileManagerData.shortcuts && Array.isArray(fileManagerData.shortcuts) ) { for (const item of fileManagerData.shortcuts) { try { if (!options.dryRun) { const newItem = { ...item, id: undefined, userId: targetUserId, createdAt: new Date().toISOString(), }; await getDb().insert(fileManagerShortcuts).values(newItem); } imported++; } catch (error) { errors.push( `Shortcut import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } } } catch (error) { errors.push( `File manager data import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); } return { imported, skipped, errors }; } private static async importDismissedAlerts( targetUserId: string, alerts: Record[], options: { replaceExisting: boolean; dryRun: boolean }, ) { let imported = 0; let skipped = 0; const errors: string[] = []; for (const alert of alerts) { try { if (options.dryRun) { imported++; continue; } const existing = await getDb() .select() .from(dismissedAlerts) .where( and( eq(dismissedAlerts.userId, targetUserId), eq(dismissedAlerts.alertId, alert.alertId as string), ), ); if (existing.length > 0 && !options.replaceExisting) { skipped++; continue; } const newAlert = { ...alert, id: undefined, userId: targetUserId, dismissedAt: new Date().toISOString(), }; if (existing.length > 0 && options.replaceExisting) { await getDb() .update(dismissedAlerts) .set(newAlert as typeof dismissedAlerts.$inferInsert) .where(eq(dismissedAlerts.id, existing[0].id)); } else { await getDb() .insert(dismissedAlerts) .values(newAlert as typeof dismissedAlerts.$inferInsert); } imported++; } catch (error) { errors.push( `Dismissed alert import failed: ${error instanceof Error ? error.message : "Unknown error"}`, ); skipped++; } } return { imported, skipped, errors }; } static async importUserDataFromJSON( targetUserId: string, jsonData: string, options: ImportOptions = {}, ): Promise { try { const exportData: UserExportData = JSON.parse(jsonData); return await this.importUserData(targetUserId, exportData, options); } catch (error) { if (error instanceof SyntaxError) { throw new Error("Invalid JSON format in import data"); } throw error; } } } export { UserDataImport, type ImportOptions, type ImportResult }; ================================================ FILE: src/components/theme-provider.tsx ================================================ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState } from "react"; type Theme = "dark" | "light" | "system"; type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: Theme; storageKey?: string; }; type ThemeProviderState = { theme: Theme; setTheme: (theme: Theme) => void; }; const initialState: ThemeProviderState = { theme: "system", setTheme: () => null, }; const ThemeProviderContext = createContext(initialState); export function ThemeProvider({ children, defaultTheme = "system", storageKey = "vite-ui-theme", ...props }: ThemeProviderProps) { const [theme, setTheme] = useState( () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, ); useEffect(() => { const root = window.document.documentElement; root.classList.remove("light", "dark"); if (theme === "system") { const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") .matches ? "dark" : "light"; root.classList.add(systemTheme); return; } root.classList.add(theme); }, [theme]); const value = { theme, setTheme: (theme: Theme) => { localStorage.setItem(storageKey, theme); setTheme(theme); }, }; return ( {children} ); } export const useTheme = () => { const context = useContext(ThemeProviderContext); if (context === undefined) throw new Error("useTheme must be used within a ThemeProvider"); return context; }; ================================================ FILE: src/components/ui/accordion.tsx ================================================ import * as React from "react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { ChevronDownIcon } from "lucide-react"; import { cn } from "@/lib/utils"; function Accordion({ ...props }: React.ComponentProps) { return ; } function AccordionItem({ className, ...props }: React.ComponentProps) { return ( ); } function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { return ( svg]:rotate-180", className, )} {...props} > {children} ); } function AccordionContent({ className, children, ...props }: React.ComponentProps) { return (
{children}
); } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; ================================================ FILE: src/components/ui/alert-dialog.tsx ================================================ import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; function AlertDialog({ ...props }: React.ComponentProps) { return ; } function AlertDialogTrigger({ ...props }: React.ComponentProps) { return ( ); } function AlertDialogPortal({ ...props }: React.ComponentProps) { return ( ); } function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogContent({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AlertDialogTitle({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogDescription({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogAction({ className, ...props }: React.ComponentProps) { return ( ); } function AlertDialogCancel({ className, ...props }: React.ComponentProps) { return ( ); } export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, }; ================================================ FILE: src/components/ui/alert.tsx ================================================ import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", { variants: { variant: { default: "bg-card text-card-foreground", destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", }, }, defaultVariants: { variant: "default", }, }, ); function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { return (
); } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Alert, AlertTitle, AlertDescription }; ================================================ FILE: src/components/ui/badge.tsx ================================================ /* eslint-disable react-refresh/only-export-components */ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, }, ); function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "span"; return ( ); } export { Badge, badgeVariants }; ================================================ FILE: src/components/ui/button-group.tsx ================================================ import { Children, ReactElement, cloneElement, isValidElement } from "react"; import { type ButtonProps } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface ButtonGroupProps { className?: string; orientation?: "horizontal" | "vertical"; children: ReactElement[] | React.ReactNode; } export const ButtonGroup = ({ className, orientation = "horizontal", children, }: ButtonGroupProps) => { const isHorizontal = orientation === "horizontal"; const isVertical = orientation === "vertical"; // Normalize and filter only valid React elements const childArray = Children.toArray(children).filter( (child): child is ReactElement => isValidElement(child), ); const totalButtons = childArray.length; return (
{childArray.map((child, index) => { const isFirst = index === 0; const isLast = index === totalButtons - 1; return cloneElement(child, { className: cn( { "rounded-l-none": isHorizontal && !isFirst, "rounded-r-none": isHorizontal && !isLast, "border-l-0": isHorizontal && !isFirst, "rounded-t-none": isVertical && !isFirst, "rounded-b-none": isVertical && !isLast, "border-t-0": isVertical && !isFirst, }, child.props.className, ), }); })}
); }; ================================================ FILE: src/components/ui/button.tsx ================================================ /* eslint-disable react-refresh/only-export-components */ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, }, ); export interface ButtonProps extends React.ComponentProps<"button">, VariantProps { asChild?: boolean; } function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { const Comp = asChild ? Slot : "button"; return ( ); } export { Button, buttonVariants, type ButtonProps }; ================================================ FILE: src/components/ui/card.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, }; ================================================ FILE: src/components/ui/chart.tsx ================================================ import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { cn } from "@/lib/utils"; // Chart Container const ChartContainer = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { return (
); }); ChartContainer.displayName = "ChartContainer"; export { ChartContainer, RechartsPrimitive }; ================================================ FILE: src/components/ui/checkbox.tsx ================================================ import * as React from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { CheckIcon } from "lucide-react"; import { cn } from "@/lib/utils"; function Checkbox({ className, ...props }: React.ComponentProps) { return ( ); } export { Checkbox }; ================================================ FILE: src/components/ui/command.tsx ================================================ "use client"; import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; import { SearchIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; function Command({ className, ...props }: React.ComponentProps) { return ( ); } function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, className, showCloseButton = true, ...props }: React.ComponentProps & { title?: string; description?: string; className?: string; showCloseButton?: boolean; }) { return ( {title} {description} {children} ); } function CommandInput({ className, ...props }: React.ComponentProps) { return (
); } function CommandList({ className, ...props }: React.ComponentProps) { return ( ); } function CommandEmpty({ ...props }: React.ComponentProps) { return ( ); } function CommandGroup({ className, ...props }: React.ComponentProps) { return ( ); } function CommandSeparator({ className, ...props }: React.ComponentProps) { return ( ); } function CommandItem({ className, ...props }: React.ComponentProps) { return ( ); } function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) { return ( ); } export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, }; ================================================ FILE: src/components/ui/dialog.tsx ================================================ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; function Dialog({ ...props }: React.ComponentProps) { return ; } function DialogTrigger({ ...props }: React.ComponentProps) { return ; } function DialogPortal({ ...props }: React.ComponentProps) { return ; } function DialogClose({ ...props }: React.ComponentProps) { return ; } function DialogOverlay({ className, ...props }: React.ComponentProps) { return ( ); } function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps & { showCloseButton?: boolean; }) { return ( {children} {showCloseButton && ( Close )} ); } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } function DialogTitle({ className, ...props }: React.ComponentProps) { return ( ); } function DialogDescription({ className, ...props }: React.ComponentProps) { return ( ); } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, }; ================================================ FILE: src/components/ui/dropdown-menu.tsx ================================================ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react"; import { cn } from "@/lib/utils"; const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, children, ...props }, ref) => ( {children} )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; const DropdownMenuContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( )); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, ...props }, ref) => ( )); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, checked, ...props }, ref) => ( {children} )); DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; const DropdownMenuRadioItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; const DropdownMenuLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { inset?: boolean; } >(({ className, inset, ...props }, ref) => ( )); DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { return ( ); }; DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, }; ================================================ FILE: src/components/ui/form.tsx ================================================ /* eslint-disable react-refresh/only-export-components */ import * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, FormProvider, useFormContext, useFormState, type ControllerProps, type FieldPath, type FieldValues, } from "react-hook-form"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, > = { name: TName; }; const FormFieldContext = React.createContext( {} as FormFieldContextValue, ); const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, >({ ...props }: ControllerProps) => { return ( ); }; const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); const { getFieldState } = useFormContext(); const formState = useFormState({ name: fieldContext.name }); const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { throw new Error("useFormField should be used within "); } const { id } = itemContext; return { id, name: fieldContext.name, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, }; }; type FormItemContextValue = { id: string; }; const FormItemContext = React.createContext( {} as FormItemContextValue, ); function FormItem({ className, ...props }: React.ComponentProps<"div">) { const id = React.useId(); return (
); } function FormLabel({ className, ...props }: React.ComponentProps) { const { error, formItemId } = useFormField(); return (