Compare commits

..

114 Commits

Author SHA1 Message Date
Karmaa 10bc491a9f Dev 2.0 (#23)
* Added user system with database features. This is fairly experimental and does not include dockerfile to automatically generate a mongodb. This should be in future commits along with ability to save hosts branching off this database feature.

* Updated README, fixed a few bugs with user creation, and added docker support to run MongoDB (needs testing)

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in installing MongoDB

* Changes to Dockerfile to fix error in connecting to sockets

* Update README.md

* Changes to connection system to support docker

* Changes to connection system to support docker

* Changes to connection system to support docker

* Changes to connection system to support docker

* Save hosts to tabs (very early version, not that many issues not just not very feature rich and has a poor UI that will be improved with more features)

* Updated launchpad UI to be expandable in the future. Updated UI for the hosts to be able to easily configure them. They stil need organizational system (folders, etc.)

* Better encryption for everything, new session login, rewrote a lot of database code changing its storage methods. Prepared for release of 2.0.

* Updated database connection method.

* Updated Profile modal to show username text more clearly

* Updated Profile modal to show username text more clearly

* Fixed control v pasting formating. Reorganized location of scripts. Visbile password and confirm password. Guest login. OpenSSH key authentication. Optional to remember password. Serach for host viewer.

* Waits for user to be able to log in. Improved UI for profile, edit and add host, and added organizational features to the host app (Folders, search, etc.)

* Updated various names for rsa keys to public keys, fixes ssh not connecting, better timing for editing host.

* Added ability to share hosts. Fixed up overall UI errors in console and cleaned up code for release.

* Fix GitHub build errors

* Attempt #1 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #2 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #3 to auto compile MongoDB into the build to exclude it from the compose.

* Attempt #3 to auto compile MongoDB into the build to exclude it from the compose.
2025-03-16 14:17:55 -05:00
Karmaa 9aa83c24ed Update README.md 2025-03-12 15:54:19 -05:00
Karmaa 54f03d73ce Dev 1.1 (#19)
* Update server.cjs (#17)

Redact only sensitive info for logging

* Changes to README.md for clear support paths.

* Switched to UTF-8 fixing non-english character bugs, revamped console logging system, fixed misc console errors, and fixed copy/paste. Warning: the terminal sizing in this version is very buggy so be warned.

* Fixed multi-line command issues with switching between split and not split.

---------

Co-authored-by: Dale Driver <firestrife23@users.noreply.github.com>
2025-03-09 18:51:05 -05:00
Karmaa b0062343c7 Dev 1.1 (#18)
* Update server.cjs (#17)

Redact only sensitive info for logging

* Changes to README.md for clear support paths.

* Switched to UTF-8 fixing non-english character bugs, revamped console logging system, fixed misc console errors, and fixed copy/paste. Warning: the terminal sizing in this version is very buggy so be warned.

* Fixed multi-line command issues with switching between split and not split.

* Shifted terminal down and to the right so its not squished up in the top left corner. Fix scroll wheel sizing and looks. Prepared for this bug update release.

---------

Co-authored-by: Dale Driver <firestrife23@users.noreply.github.com>
2025-03-09 18:46:09 -05:00
Karmaa 09677caa26 Update README.md 2025-03-06 20:19:08 -06:00
Karmaa 7dc1bce272 Merge branch 'main' into dev-1.1
Merge dev-1.1 into main branch.
2025-03-06 20:00:04 -06:00
Karmaa 58ba96654a Fixed discord buton in README.md 2025-03-06 19:48:37 -06:00
Karmaa 0b00c26f8a Fixed small bug in terminal resizing. Updated readme with discord. 2025-03-06 19:45:17 -06:00
Karmaa 9ff199ea96 Final preparations for release. Fixed auto resizing bug when smaller terminal size. 2025-03-06 18:43:27 -06:00
Karmaa 0d464cdf56 Added RSA support for authentication. Furthered preparations for release. 2025-03-06 17:52:27 -06:00
Karmaa 963e54bf15 Fixed up some UI issues and auto-sizing for the terminal. Finished up preparations for full release. 2025-03-05 22:49:34 -06:00
Karmaa 62262e5bf3 Fixed entrypoint to start server automatically and made some scroll wheel changes. Getting very close to release point. 2025-03-05 16:29:57 -06:00
Karmaa e800064f05 Updated conection process to work hopfully with docker. 2025-03-05 16:15:51 -06:00
Karmaa 713d43fc52 Made some UI changes and code cleanup. Split screen working great, readying for release for user testing (without persistence or launchpad for now). 2025-03-05 14:46:46 -06:00
Karmaa a6295e5e7e Fixed up the split screen a lot. 3+ screens at once causes inability to unsplit. Still a WIP. 2025-03-05 00:43:57 -06:00
Karmaa 2038a84c15 Fixed up split screen a little. Still buggy. Made some UI changes too. 2025-03-02 00:04:21 -06:00
Karmaa cf8fff572a Added initial split screen mode and overall better UI sizing for terminals. (very buggy) 2025-02-26 00:58:22 -06:00
Karmaa cf5f0c41eb Another UI overall. Shift to launchpad, removed sidebar, turned everything into components. 2025-02-25 00:08:38 -06:00
Karmaa b43ca54fa0 UI Overhaul (Switch to tailwindcss and MUI) 2025-02-23 22:46:30 -06:00
Karmaa 08dbfdaf8f Attempt #7 to fix CORS issue and docker build. 2025-02-22 01:40:45 -06:00
Karmaa ba502de450 Attempt #6 to fix CORS issue and docker build. 2025-02-22 01:25:31 -06:00
Karmaa 226cdcb7a0 Attempt #5 to fix CORS issue and docker build. 2025-02-22 01:12:45 -06:00
Karmaa a9d510aba4 Attempt #4 to fix CORS issue and docker build. 2025-02-22 00:59:38 -06:00
Karmaa 5c5fe57dad Attempt #3 to fix CORS issue and docker build. 2025-02-22 00:39:34 -06:00
Karmaa 1addd43039 Attempt #2 to fix CORS issue and docker build. 2025-02-22 00:24:47 -06:00
Karmaa 946f5daaa9 Attempt #1 to fix CORS issue and docker build. 2025-02-22 00:18:28 -06:00
Karmaa e42c8c9788 Added ARM support 2025-02-21 23:59:40 -06:00
Karmaa a6b03aa792 Updated socket connection code to support docker. 2025-02-09 21:35:45 -06:00
Karmaa 175225dcd7 Updated socket connection code to support docker. Also updated dockerfile and entrypoint to support new file structure. 2025-02-09 21:16:31 -06:00
Karmaa f2918e02f6 Updated socket connection code to support docker 2025-02-09 21:07:09 -06:00
Karmaa cb18a0cd87 Updated nginx and connection to support local and docker runtime 2025-02-09 20:29:39 -06:00
Karmaa 98a45e9161 Updated dockerfile for new file structure 2025-02-09 02:23:31 -06:00
Karmaa 95c2994b3c Updated dockerfile for new file structure 2025-02-09 02:15:19 -06:00
Karmaa 0b302fc860 Updated dockerfile for new file structure 2025-02-09 02:13:44 -06:00
Karmaa c5429ff2e8 Updated dockerfile for new file structure 2025-02-09 02:05:20 -06:00
Karmaa 9ab46cddd4 Updated dockerfile for new file structure 2025-02-09 02:03:39 -06:00
Karmaa 128ecbea04 Updated dockerfile for new file structure 2025-02-09 02:02:35 -06:00
Karmaa 9f7331d72a Updated dockerfile for new file structure 2025-02-09 02:00:30 -06:00
Karmaa 968e0cfeea Updated dockerfile for new file structure 2025-02-09 01:59:24 -06:00
Karmaa c7e06f422d Updated dockerfile for new file structure 2025-02-09 01:57:49 -06:00
Karmaa d799b8fe42 Updated dockerfile for new file structure 2025-02-09 01:55:36 -06:00
Karmaa e058892118 Updated docker compose for new file structure 2025-02-09 01:50:59 -06:00
Karmaa 33060eb1e4 Fixed tab spacing, nano reset, and coloring. 2025-02-09 01:48:59 -06:00
Karmaa 3b0eddca27 Add LICENSE 2025-02-06 23:48:16 -06:00
Karmaa 8afadf6176 Initial version 1.1 2025-02-06 23:47:21 -06:00
Karmaa 002683a2f0 Merge pull request #10 from LukeGus/release-1.0
Merge pull request #8 from LukeGus/release-1.0 (#9)
2024-12-12 21:20:18 -06:00
Karmaa 24dcdc6751 Merge pull request #8 from LukeGus/release-1.0 (#9) 2024-12-12 21:19:47 -06:00
Karmaa bbaa9bf226 Merge pull request #8 from LukeGus/release-1.0
Release 1.0
2024-12-12 19:51:16 -06:00
Karmaa 37e314edff Update App.jsx 2024-12-12 19:50:59 -06:00
Karmaa b2f5459817 Merge pull request #7 from LukeGus/main
Release 1.0
2024-12-12 19:48:05 -06:00
Karmaa cd53d15a04 Merge branch 'main' of https://github.com/LukeGus/Termix 2024-12-12 19:45:46 -06:00
Karmaa a17e72eec8 Update workspace.xml 2024-12-12 19:44:33 -06:00
Karmaa 82b57a6af5 Merge branch 'release-1.0' 2024-12-12 19:44:27 -06:00
LukeGus 3c474d3b52 Fix stupid error (forgot to change websocket to wsUrl) 2024-12-12 19:22:39 -06:00
Karmaa 15e1fd215e Release 1.0 (#3)
* Nano and timeout error fix #1

* Nano and timeout error fix #2

* Nano and timeout error fix #3

* Nano and timeout error fix #4

* Nano and timeout error fix #5

* Nano and timeout error fix #6

* Nano and timeout error fix #7 (plus css changes for hide buttons)

* Nano and timeout error fix #8 (zoom method)

* Nano zoom fix #9

* Nano zoom fix #10

* Nano zoom fix #11

* Nano zoom fix #12

* Nano zoom fix #13

* Nano zoom fix #14

* Nano zoom fix #15

* Nano zoom fix #16 (ssh-2-promise)

* Nano zoom fix #17 (ssh-2-promise port  fix)

* Full return back to old code with minor fixes

* Terminal size fix #18 (rows & cols fix)

* Test ntfy build notification system

* Silent resize cmds & Auto resize terminal #1

* Silent resize cmds & Auto resize terminal #2

* Docker build update

* Docker build update #2

* Docker build update #3

* Docker build update #4 (cache and cleanup)

* Docker build update #5 (cache and cleanup)

* Docker build update #6 (cache and cleanup)

* Docker build update #7 (final)

* Docker build update #8 (nevermind not finanl)

* Docker build update #9 (nevermind not finanl)

* Docker build update #10

* Docker build update #10 (I hope final)

* Docker build update #10 (actual final)

* Release 1.0!!!!

* Repo clean-up

* Remove files ignored by .gitignore

* Change project name, create logo, change README.md to be soon updated.

* Update README.md #1 (also added MIT License)

* Update README.md #2

* Update README.md #3

* Update README.md #4

* Update README.md #5

* Update README.md #6

* Update README.md #7

* Update README.md

* Add images

* Update image name

* Update README.md

* Final changes to release-1.0

* Update README.md

* Update name (need to fix logo)

* Final updates (I hope for real this time)

* Fix web-socket timeout.

* Fix timeout on close.
2024-12-12 18:46:43 -06:00
LukeGus 4d8263a788 Fix timeout on close. 2024-12-10 21:32:58 -06:00
LukeGus 2061ac5e3b Fix web-socket timeout. 2024-12-10 21:28:39 -06:00
LukeGus e1f9251186 Final updates (I hope for real this time) 2024-12-10 21:03:50 -06:00
LukeGus b60924b9c7 Merge remote-tracking branch 'origin/release-1.0' into release-1.0 2024-12-08 15:22:54 -06:00
LukeGus bd844c7f11 Update name (need to fix logo) 2024-12-08 15:22:39 -06:00
Karmaa ec64bb401b Update README.md 2024-12-08 15:10:07 -06:00
LukeGus 963ac01671 Final changes to release-1.0 2024-12-08 15:09:38 -06:00
Karmaa 50c07c2f3a Update README.md 2024-12-08 15:06:47 -06:00
LukeGus 202de2123b Update image name 2024-12-08 15:06:20 -06:00
LukeGus f677a54551 Add images 2024-12-08 15:02:04 -06:00
Karmaa 0554f9c0fc Update README.md 2024-12-08 14:55:27 -06:00
Karmaa 1901c09a70 Update README.md #7 2024-12-08 14:45:54 -06:00
Karmaa 8229048e4c Update README.md #6 2024-12-08 14:20:20 -06:00
Karmaa d7ca5eccf3 Update README.md #5 2024-12-08 14:17:30 -06:00
LukeGus 1adb64524c Update README.md #4 2024-12-08 14:14:28 -06:00
LukeGus 56ed93bb88 Update README.md #3 2024-12-08 01:50:40 -06:00
LukeGus 861194d8d1 Update README.md #2 2024-12-08 01:48:43 -06:00
LukeGus 74ce228fe1 Update README.md #1 (also added MIT License) 2024-12-08 01:42:43 -06:00
LukeGus 17d2e65187 Change project name, create logo, change README.md to be soon updated. 2024-12-08 01:24:14 -06:00
LukeGus 376fbb1b66 Remove files ignored by .gitignore 2024-12-07 17:30:55 -06:00
LukeGus e68a508dec Repo clean-up 2024-12-07 14:46:20 -06:00
LukeGus ad7532e88d Release 1.0!!!! 2024-12-07 14:35:56 -06:00
LukeGus c2405b0df2 Docker build update #10 (actual final) 2024-12-07 02:09:10 -06:00
LukeGus d422a9ad25 Docker build update #10 (I hope final) 2024-12-07 02:06:19 -06:00
LukeGus ebf00630fd Docker build update #10 2024-12-07 01:59:05 -06:00
LukeGus 0bb4f2fb0b Docker build update #9 (nevermind not finanl) 2024-12-07 01:55:36 -06:00
LukeGus bc49d646b3 Docker build update #8 (nevermind not finanl) 2024-12-07 01:49:32 -06:00
LukeGus 0f764dfb98 Docker build update #7 (final) 2024-12-07 01:45:55 -06:00
LukeGus 2fa968662e Merge remote-tracking branch 'origin/release-1.0' into release-1.0
# Conflicts:
#	.github/workflows/docker-image.yml
#	.idea/workspace.xml
2024-12-07 01:42:35 -06:00
LukeGus b5da3994b0 Docker build update #6 (cache and cleanup) 2024-12-07 01:42:16 -06:00
LukeGus 1820f36155 Docker build update #5 (cache and cleanup) 2024-12-07 01:35:16 -06:00
LukeGus 87107b8cc2 Docker build update #4 (cache and cleanup) 2024-12-07 01:31:37 -06:00
LukeGus 0cfde2410e Docker build update #3 2024-12-07 01:27:06 -06:00
LukeGus 25f8ce74ea Docker build update #2 2024-12-07 01:24:27 -06:00
LukeGus 02acc72bf6 Docker build update 2024-12-07 01:18:52 -06:00
LukeGus a81b4834ce Silent resize cmds & Auto resize terminal #2 2024-12-07 00:44:59 -06:00
LukeGus a2a441cdca Silent resize cmds & Auto resize terminal #1 2024-12-07 00:32:06 -06:00
LukeGus 4b927decce Test ntfy build notification system 2024-12-06 20:42:59 -06:00
LukeGus bda3fe9b31 Terminal size fix #18 (rows & cols fix) 2024-12-06 20:21:14 -06:00
LukeGus fe09966196 Full return back to old code with minor fixes 2024-12-06 18:54:54 -06:00
LukeGus bc14f803a2 Nano zoom fix #17 (ssh-2-promise port fix) 2024-12-05 20:30:58 -06:00
LukeGus b3e43a45af Nano zoom fix #16 (ssh-2-promise) 2024-12-05 20:25:23 -06:00
LukeGus 95573f7e37 Nano zoom fix #15 2024-12-05 20:04:38 -06:00
LukeGus 0ed7d7a083 Nano zoom fix #14 2024-12-05 19:58:24 -06:00
LukeGus 474965835f Nano zoom fix #13 2024-12-05 19:49:18 -06:00
LukeGus 1f75dc0bd2 Nano zoom fix #12 2024-12-05 19:42:08 -06:00
LukeGus 7b545367dc Nano zoom fix #11 2024-12-05 19:34:17 -06:00
LukeGus 1a0d224ae0 Nano zoom fix #11 2024-12-05 19:19:04 -06:00
LukeGus 848b388734 Nano zoom fix #10 2024-12-04 22:07:51 -06:00
LukeGus ea2343551d Nano zoom fix #9 2024-12-04 22:04:01 -06:00
LukeGus d46ddfe6ee Nano and timeout error fix #8 (zoom method) 2024-12-04 21:58:40 -06:00
LukeGus a407bf15f7 Nano and timeout error fix #7 (plus css changes for hide buttons) 2024-12-04 21:53:21 -06:00
LukeGus 3721edc128 Nano and timeout error fix #6 2024-12-04 21:48:06 -06:00
LukeGus 0900017218 Nano and timeout error fix #5 2024-12-04 21:43:38 -06:00
LukeGus f14db79784 Nano and timeout error fix #4 2024-12-04 21:34:56 -06:00
LukeGus 870cd9245e Nano and timeout error fix #3 2024-12-04 21:34:37 -06:00
LukeGus 246bb8fb26 Nano and timeout error fix #2 2024-12-04 21:30:37 -06:00
LukeGus 1139160e48 Nano and timeout error fix #1 2024-12-04 21:19:30 -06:00
LukeGus b6a3f881a8 Initial Commit 2024-12-04 21:04:46 -06:00
231 changed files with 9550 additions and 129807 deletions
-129
View File
@@ -1,129 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
build
.next
.nuxt
# Development files
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
README-CN.md
CONTRIBUTING.md
LICENSE
# Docker files (avoid copying docker files into docker)
# docker/ - commented out to allow entrypoint.sh to be copied
# Repository images
repo-images/
# Uploads directory
uploads/
# Electron files (not needed for Docker)
electron/
electron-builder.json
# Development and build artifacts
*.log
*.tmp
*.temp
# Font files (we'll optimize these in Dockerfile)
# public/fonts/*.ttf
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
-1
View File
@@ -1 +0,0 @@
github: [LukeGus]
-82
View File
@@ -1,82 +0,0 @@
name: Bug report
description: Create a report to help Termix improve
title: "[BUG]"
labels: [bug]
assignees: []
body:
- type: input
id: title
attributes:
label: Title
description: Brief, descriptive title for the bug
placeholder: "Brief description of the bug"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
description: How are you using Termix?
options:
- Website - Firefox
- Website - Safari
- Website - Chrome
- Website - Other Browser
- App - Windows
- App - Linux
- App - iOS
- App - Android
validations:
required: true
- type: dropdown
id: server-installation-method
attributes:
label: Server Installation Method
description: How is the Termix server installed?
options:
- Docker
- Manual Build
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Find your version in the User Profile tab
placeholder: "e.g., 1.7.0"
validations:
required: true
- type: checkboxes
id: troubleshooting
attributes:
label: Troubleshooting
description: Please check all that apply
options:
- label: I have examined logs and tried to find the issue
- label: I have reviewed opened and closed issues
- label: I have tried restarting the application
- type: textarea
id: problem-description
attributes:
label: The Problem
description: Describe the bug in detail. Include as much information as possible with screenshots if applicable.
placeholder: "Describe what went wrong..."
validations:
required: true
- type: textarea
id: reproduction-steps
attributes:
label: How to Reproduce
description: Use as few steps as possible to reproduce the issue
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other context about the problem
placeholder: "Add any other context about the problem here..."
@@ -1,36 +0,0 @@
name: Feature request
description: Suggest an idea for Termix
title: "[FEATURE]"
labels: [enhancement]
assignees: []
body:
- type: input
id: title
attributes:
label: Title
description: Brief, descriptive title for the feature request
placeholder: "Brief description of the feature"
validations:
required: true
- type: textarea
id: related-issue
attributes:
label: Is it related to an issue?
description: Describe the problem this feature would solve
placeholder: "Describe what problem this feature would solve..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: The Solution
description: Describe your proposed solution in detail
placeholder: "Describe how you envision this feature working..."
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other context or screenshots about the feature request
placeholder: "Add any other context about the feature request here..."
-40
View File
@@ -1,40 +0,0 @@
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"
-31
View File
@@ -1,31 +0,0 @@
# 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/LukeGus/Termix/blob/main/CONTRIBUTING.md)
+29 -85
View File
@@ -1,128 +1,73 @@
name: Build and Push Docker Image
on:
push:
branches:
- development
workflow_dispatch:
inputs:
tag_name:
description: "Custom tag name for the Docker image"
required: false
default: ""
registry:
description: "Docker registry to push to"
required: true
default: "ghcr"
type: choice
options:
- "ghcr"
- "dockerhub"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Node.js
uses: actions/setup-node@v2
with:
platforms: arm64
node-version: '18'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
driver-opts: |
image=moby/buildkit:master
network=host
- name: Install Dependencies and Build Frontend
run: |
cd src
npm ci
npm run build
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
*/*/node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
restore-keys: |
${{ runner.os }}-buildx-${{ github.ref_name }}-
${{ runner.os }}-buildx-
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
if: github.event.inputs.registry != 'dockerhub'
uses: docker/login-action@v3
- name: Login to Docker Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: github.event.inputs.registry == 'dockerhub'
uses: docker/login-action@v3
with:
username: bugattiguy527
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine Docker image tag
run: |
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV
if [ "${{ github.event.inputs.tag_name }}" != "" ]; then
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
IMAGE_TAG="latest"
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
IMAGE_TAG="development-latest"
echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
if [ "${{ github.event.inputs.tag_name }}" == "" ]; then
IMAGE_TAG="${{ github.ref_name }}-development-latest"
else
IMAGE_TAG="${{ github.ref_name }}"
IMAGE_TAG="${{ github.event.inputs.tag_name }}"
fi
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Determine registry and image name
if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then
echo "REGISTRY=docker.io" >> $GITHUB_ENV
echo "IMAGE_NAME=bugattiguy527/termix" >> $GITHUB_ENV
else
echo "REGISTRY=ghcr.io" >> $GITHUB_ENV
echo "IMAGE_NAME=$REPO_OWNER/termix" >> $GITHUB_ENV
fi
- name: Build and Push Multi-Arch Docker Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
outputs: type=registry,compression=zstd,compression-level=19
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }}
labels: org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Move cache
- name: Notify via ntfy
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
curl -d "Docker image build and push completed successfully for tag: ${{ env.IMAGE_TAG }}" \
https://ntfy.karmaa.site/termix-build
- name: Delete all untagged image versions
if: success() && github.event.inputs.registry != 'dockerhub'
uses: quartx-analytics/ghcr-cleaner@v1
with:
owner-type: user
@@ -131,7 +76,6 @@ jobs:
delete-untagged: true
- name: Cleanup Docker Images Locally
if: always()
run: |
docker image prune -af
docker system prune -af --volumes
docker system prune -af --volumes
-93
View File
@@ -1,93 +0,0 @@
name: Build Electron App
on:
workflow_dispatch:
inputs:
build_type:
description: "Build type to run"
required: true
default: "all"
type: choice
options:
- all
- windows
- linux
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 == ''
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: npm ci
- name: Build Windows Portable
run: npm run build:win-portable
- name: Build Windows Installer
run: npm run build:win-installer
- name: Create Windows Portable zip
run: |
Compress-Archive -Path "release/win-unpacked/*" -DestinationPath "Termix-Windows-Portable.zip"
- name: Upload Windows Portable Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Windows-Portable
path: Termix-Windows-Portable.zip
retention-days: 30
- name: Upload Windows Installer Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Windows-Installer
path: release/*.exe
retention-days: 30
build-linux:
runs-on: ubuntu-latest
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
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: npm ci
- name: Build Linux Portable
run: npm run build:linux-portable
- name: Create Linux Portable zip
run: |
cd release/linux-unpacked
zip -r ../../Termix-Linux-Portable.zip *
cd ../..
- name: Upload Linux Portable Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Linux-Portable
path: Termix-Linux-Portable.zip
retention-days: 30
+139 -4
View File
@@ -22,8 +22,143 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/db/
/release/
/.claude/
/ssl/
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.env.local
.env.development.local
.env.test.local
.env.production.local
.bash_history
.bashrc
.init_done
.profile
.sudo_as_admin_successful
.wget-hsts
.git-credentials
.docker/
.bash_logout
# VSCode Files
.vscode-server/
# Configs
.config/
# .dotnet
.dotnet/
# .local
.local/
/docker/docker-compose.yml
/src/data/
/docker/mongodb/
/docker/docker-compose.yml
-1
View File
@@ -1 +0,0 @@
22
-3
View File
@@ -1,3 +0,0 @@
# Ignore artifacts:
build
coverage
-1
View File
@@ -1 +0,0 @@
{}
-128
View File
@@ -1,128 +0,0 @@
# 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.
-106
View File
@@ -1,106 +0,0 @@
# 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/LukeGus/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/LukeGus/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 with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
repo.
+18 -10
View File
@@ -1,13 +1,21 @@
Copyright 2025 Luke Gustafson
MIT License
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
Copyright (c) 2025 Luke Gustafson
http://www.apache.org/licenses/LICENSE-2.0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
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.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-115
View File
@@ -1,115 +0,0 @@
# 仓库统计
<p align="center">
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
</p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
<p align="center">
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
<br>
<small style="color: #666;">2025年9月1日获得</small>
</p>
#### 核心技术
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#)
[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#)
<br />
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p>
如果你愿意,可以在这里支持这个项目!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# 概览
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
# 功能
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
- **用户认证** - 安全的用户管理,支持管理员控制、OIDC 和双因素认证(TOTP)
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
- **语言支持** - 内置中英文支持
# 计划功能
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
- **主题定制** - 修改所有工具的主题风格
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP(有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
- **移动端支持** - 支持移动应用或 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"
volumes:
termix-data:
driver: local
```
# 支持
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
# 展示
<p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p>
<p align="center">
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
你的浏览器不支持 video 标签。
</video>
</p>
# 许可证
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。
+30 -104
View File
@@ -1,136 +1,62 @@
# Repo Stats
<p align="center">
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
</p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
<p align="center">
<img src="./repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
<br>
<small style="color: #666;">Achieved on September 1st, 2025</small>
</p>
#### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#)
[![Javascript Badge](https://img.shields.io/badge/-Javascript-F0DB4F?style=flat-square&labelColor=black&logo=javascript&logoColor=F0DB4F)](#)
[![Nodejs Badge](https://img.shields.io/badge/-Nodejs-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![HTML Badge](https://img.shields.io/badge/-HTML-E34F26?style=flat-square&labelColor=black&logo=html5&logoColor=E34F26)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#)
[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#)
[![MongoDB Badge](https://img.shields.io/badge/-MongoDB-47A248?style=flat-square&labelColor=black&logo=mongodb&logoColor=47A248)](#)
[![MUI Joy Badge](https://img.shields.io/badge/-MUI%20Joy-007FFF?style=flat-square&labelColor=black&logo=mui&logoColor=007FFF)](#)
<br />
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
<img alt="Termix Banner" src=./repo-images/TermixLogo.png style="width: 125px; height: auto;"> </a>
</p>
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)
[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://paypal.me/LukeGustafson803)
# Overview
Termix is an open-source forever free self-hosted SSH (other protocols planned, see [Planned Features](#planned-features)) server management panel inspired by [Nexterm](https://github.com/gnmyt/Nexterm). Its purpose is to provide an all-in-one docker-hosted web solution to manage your servers in one easy place. I'm using this project to help me learn [React](https://github.com/facebook/react), [Vite](https://github.com/vitejs/vite-plugin-react), and [Docker](https://www.docker.com) but also because I could never settle on a server management software that I enjoyed to use.
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
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, and remote file management, with many more tools to come.
> [!WARNING]
> This app is in the VERY early stages of development. Expect bugs, data loss, and unexplainable issues! For that reason, I recommend you securely tunnel your connection to Termix through a VPN.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **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.
- **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 deploying of SSH keys
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Database Encryption** - SQLite database files encrypted at rest with automatic encryption/decryption
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data with incremental sync
- **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
- **Languages** - Built-in support for English, Chinese, and German
- **Platform Support** - Available as a web app, desktop application (Windows & Linux), and dedicated mobile app for iOS and Android. macOS and iPadOS support is planned.
- SSH
- Split Screen (Up to 4) & Tab System
- User Authentication
- Save Hosts (and easily view, connect, and manage them)
# Planned Features
See [Projects](https://github.com/users/LukeGus/projects/3) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md).
- VNC
- RDP
- SFTP (build in file transfer)
- ChatGPT/Ollama Integration (for commands)
- Apps (like notes, AI, etc)
- Terminal Themes
- User Management (roles, permissions, etc.)
- SSH Tunneling
- More Authentication Methods
- More Security Features (like 2FA, etc.)
# Installation
Supported Devices:
- Website (any modern browser like Google, Safari, and Firefox)
- Windows (app)
- Linux (app)
- iOS (app)
- Android (app)
- iPadOS and macOS are in progress
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:
```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"
volumes:
termix-data:
driver: local
```
Visit the Termix [Wiki](https://github.com/LukeGus/Termix/wiki) for information on how to install Termix. You can also use these links to go directly to guide. [Docker](https://github.com/LukeGus/Termix/wiki/Docker) or [Manual](https://github.com/LukeGus/Termix/wiki/Manual).
# Support
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
repo.
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo. If you would like to support me financially, you can on [Paypal](https://paypal.me/LukeGustafson803).
# Show-off
<p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p>
<p align="center">
<img src="./repo-images/Image 3.png" width="400" alt="Termix Demo 3"/>
<img src="./repo-images/Image 4.png" width="400" alt="Termix Demo 4"/>
</p>
<p align="center">
<img src="./repo-images/Image 5.png" width="400" alt="Termix Demo 5"/>
<img src="./repo-images/Image 6.png" width="400" alt="Termix Demo 6"/>
</p>
<p align="center">
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/88936e0d-2399-4122-8eee-c255c25da48c" width="800" controls>
Your browser does not support the video tag.
</video>
</p>
![Demo Image](repo-images/DemoImage1.png)
![Demo Image](repo-images/DemoImage2.png)
# License
Distributed under the Apache License Version 2.0. See LICENSE for more information.
Distributed under the MIT license. See LICENSE for more information.
-5
View File
@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report any vulnerabilities to [GitHub Security](https://github.com/LukeGus/Termix/security/advisories).
-21
View File
@@ -1,21 +0,0 @@
{
"$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"
}
+44 -72
View File
@@ -1,89 +1,61 @@
# Stage 1: Install dependencies
FROM node:22-slim AS deps
# Stage 1: Build frontend
FROM --platform=$BUILDPLATFORM node:18 AS frontend-builder
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN rm -rf node_modules package-lock.json && \
npm install --force && \
npm cache clean --force
# Stage 2: Build frontend
FROM deps AS frontend-builder
WORKDIR /app
RUN npm install
COPY . .
RUN npm run build
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
RUN npm cache clean --force && \
npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
# Stage 2: Build backend
FROM --platform=$BUILDPLATFORM node:18 AS backend-builder
WORKDIR /app
COPY . .
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
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 install
COPY src/backend/ ./src/backend/
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
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 && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads && \
chown -R node:node /app/data /app/uploads && \
useradd -r -s /bin/false nginx
# Stage 3: Final production image
FROM mongo:5
# Install Node.js
RUN apt-get update && apt-get install -y \
curl \
nginx \
python3 \
build-essential \
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Configure nginx
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --chown=nginx:nginx --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --chown=nginx:nginx --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
COPY --chown=nginx:nginx --from=frontend-builder /app/public/fonts /usr/share/nginx/html/fonts
# Setup backend
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=backend-builder /app/src/backend ./src/backend
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 ./
# Create directories for MongoDB and nginx
RUN mkdir -p /data/db && \
mkdir -p /var/log/nginx && \
mkdir -p /var/lib/nginx && \
mkdir -p /var/log/mongodb && \
chown -R mongodb:mongodb /data/db /var/log/mongodb && \
chown -R www-data:www-data /var/log/nginx /var/lib/nginx
VOLUME ["/app/data"]
# Set environment variables
ENV MONGO_URL=mongodb://localhost:27017/termix \
MONGODB_DATA_DIR=/data/db \
MONGODB_LOG_DIR=/var/log/mongodb
EXPOSE ${PORT} 30001 30002 30003 30004 30005
# Create volume for MongoDB data
VOLUME ["/data/db"]
# Expose ports
EXPOSE 8080 8081 8082 27017
# Use a entrypoint script to run all services
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
+21 -110
View File
@@ -1,121 +1,32 @@
#!/bin/sh
#!/bin/bash
set -e
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}
# Start MongoDB
echo "Starting MongoDB..."
mongod --fork --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log
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="/etc/nginx/nginx-https.conf"
else
echo "SSL disabled - using HTTP-only configuration (default)"
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
fi
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
mkdir -p /app/data /app/uploads
chown -R node:node /app/data /app/uploads
chmod 755 /app/data /app/uploads
if [ "$ENABLE_SSL" = "true" ]; then
echo "Checking SSL certificate configuration..."
mkdir -p /app/data/ssl
chown -R node:node /app/data/ssl
chmod 755 /app/data/ssl
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
chown node:node /app/data/ssl/termix.key /app/data/ssl/termix.crt
rm -f /app/data/ssl/openssl.conf
echo "SSL certificates generated successfully for domain: $DOMAIN"
fi
fi
# Wait for MongoDB to be ready
echo "Waiting for MongoDB to start..."
until mongosh --eval "print(\"waited for connection\")" > /dev/null 2>&1; do
sleep 0.5
done
echo "MongoDB has started"
# Start nginx
echo "Starting nginx..."
nginx
echo "Starting backend services..."
# Change to app directory
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
# Start the SSH service
echo "Starting SSH service..."
node src/backend/ssh.cjs &
if command -v su-exec > /dev/null 2>&1; then
su-exec node node dist/backend/backend/starter.js
else
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
fi
# Start the database service
echo "Starting database service..."
node src/backend/database.cjs &
echo "All services started"
tail -f /dev/null
# Keep the container running and show MongoDB logs
echo "All services started. Tailing MongoDB logs..."
tail -f $MONGODB_LOG_DIR/mongodb.log
-267
View File
@@ -1,267 +0,0 @@
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
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-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
location ~* \.map$ {
return 404;
access_log off;
log_not_found off;
}
location ~ ^/users(/.*)?$ {
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 ~ ^/version(/.*)?$ {
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 ~ ^/releases(/.*)?$ {
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 ~ ^/alerts(/.*)?$ {
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 ~ ^/credentials(/.*)?$ {
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
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 $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 $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 $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/ {
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 /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 $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_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 /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003;
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 /ssh/file_manager/recent {
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 /ssh/file_manager/pinned {
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 /ssh/file_manager/shortcuts {
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 /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30004;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location /health {
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 ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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 ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
+23 -223
View File
@@ -3,254 +3,54 @@ events {
}
http {
include mime.types;
default_type application/octet-stream;
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
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;
sendfile on;
keepalive_timeout 65;
server {
listen ${PORT};
server_name localhost;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
listen 8080;
server_name localhost;
# Serve the React app
location / {
root /usr/share/nginx/html;
index index.html index.htm;
root /usr/share/nginx/html;
index index.html index.htm;
}
location ~* \.map$ {
return 404;
access_log off;
log_not_found off;
}
location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
# Proxy SSH socket requests
location /ssh.io/ {
proxy_pass http://127.0.0.1:8081;
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 ~ ^/version(/.*)?$ {
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 ~ ^/releases(/.*)?$ {
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 ~ ^/alerts(/.*)?$ {
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 ~ ^/credentials(/.*)?$ {
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
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 $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 $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 $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/ {
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 /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 Connection "Upgrade";
proxy_set_header Host $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_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 /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003;
# Proxy MongoDB socket requests
location /database.io/ {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $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 /ssh/file_manager/recent {
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 /ssh/file_manager/pinned {
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 /ssh/file_manager/shortcuts {
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 /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
proxy_pass http://127.0.0.1:30004;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location /health {
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 ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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 ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:30005;
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;
}
error_page 500 502 503 504 /50x.html;
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
root /usr/share/nginx/html;
}
}
}
-62
View File
@@ -1,62 +0,0 @@
{
"appId": "com.termix.app",
"productName": "Termix",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"public/**/*",
"!**/node_modules/**/*",
"!src/**/*",
"!*.md",
"!tsconfig*.json",
"!vite.config.ts",
"!eslint.config.js"
],
"asarUnpack": ["node_modules/node-fetch/**/*"],
"extraMetadata": {
"main": "electron/main.cjs"
},
"buildDependenciesFromSource": false,
"nodeGypRebuild": false,
"npmRebuild": false,
"win": {
"target": "nsis",
"icon": "public/icon.ico",
"executableName": "Termix"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-Setup-${version}.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Termix",
"uninstallDisplayName": "Termix"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64"]
},
{
"target": "tar.gz",
"arch": ["x64"]
}
],
"icon": "public/icon.png",
"category": "Development",
"executableName": "termix",
"desktop": {
"entry": {
"Name": "Termix",
"Comment": "A web-based server management platform",
"Keywords": "terminal;ssh;server;management;",
"StartupWMClass": "termix"
}
}
}
}
-493
View File
@@ -1,493 +0,0 @@
const { app, BrowserWindow, shell, ipcMain, dialog } = require("electron");
const path = require("path");
const fs = require("fs");
const os = require("os");
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");
if (process.platform === "linux") {
app.commandLine.appendSwitch("--no-sandbox");
app.commandLine.appendSwitch("--disable-setuid-sandbox");
app.commandLine.appendSwitch("--disable-dev-shm-usage");
}
let mainWindow = null;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
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 createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Termix",
icon: isDev
? path.join(__dirname, "..", "public", "icon.png")
: path.join(process.resourcesPath, "public", "icon.png"),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
},
show: false,
});
if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false);
}
if (isDev) {
mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, "..", "dist", "index.html");
mainWindow.loadFile(indexPath);
}
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});
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 (process.platform === "darwin") {
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 = "LukeGus";
const REPO_NAME = "Termix";
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
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 {
let fetch;
try {
fetch = globalThis.fetch || require("node-fetch");
} catch (e) {
const https = require("https");
const http = require("http");
const { URL } = require("url");
fetch = (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,
secureProtocol: "TLSv1_2_method",
checkServerIdentity: () => undefined,
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
honorCipherOrder: true,
});
}
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();
});
};
}
const response = await fetch(`${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-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("test-server-connection", async (event, serverUrl) => {
try {
const https = require("https");
const http = require("http");
const { URL } = require("url");
const fetch = (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,
secureProtocol: "TLSv1_2_method",
checkServerIdentity: () => undefined,
ciphers: "ALL:!ADH:!LOW:!EXP:!MD5:@STRENGTH",
honorCipherOrder: true,
});
}
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();
});
};
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
try {
const response = await fetch(healthUrl, {
method: "GET",
timeout: 10000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
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 fetch(versionUrl, {
method: "GET",
timeout: 10000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
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 };
}
});
app.whenReady().then(() => {
createWindow();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
app.on("will-quit", () => {
console.log("App will quit...");
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
-28
View File
@@ -1,28 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) =>
ipcRenderer.invoke("save-server-config", config),
testServerConnection: (serverUrl) =>
ipcRenderer.invoke("test-server-connection", serverUrl),
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateDownloaded: (callback) =>
ipcRenderer.on("update-downloaded", callback),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true,
isDev: process.env.NODE_ENV === "development",
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
});
window.IS_ELECTRON = true;
+31 -16
View File
@@ -1,23 +1,38 @@
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";
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default tseslint.config([
globalIgnores(["dist"]),
export default [
{ ignores: ['dist'] },
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]);
]
+1 -1
View File
@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-2305
View File
File diff suppressed because it is too large Load Diff
+4886 -12255
View File
File diff suppressed because it is too large Load Diff
+44 -115
View File
@@ -1,131 +1,60 @@
{
"name": "termix",
"private": true,
"version": "1.7.2",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
"version": "0.0.0",
"type": "module",
"scripts": {
"clean": "npx prettier . --write",
"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",
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
"build:win-portable": "npm run build && electron-builder --win --dir",
"build:win-installer": "npm run build && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && electron-builder --linux --dir",
"build:linux-appimage": "npm run build && electron-builder --linux AppImage",
"build:linux-targz": "npm run build && electron-builder --linux tar.gz",
"test:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-test.js",
"migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js"
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"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-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-slot": "^1.2.3",
"@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.11",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/react-codemirror": "^4.24.1",
"@xterm/addon-clipboard": "^0.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/inter": "^5.1.1",
"@mui/icons-material": "^6.4.7",
"@mui/joy": "^5.0.0-beta.51",
"@tailwindcss/vite": "^4.0.8",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@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",
"cookie-parser": "^1.4.7",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
"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-dom": "^19.1.0",
"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",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"speakeasy": "^2.0.0",
"crypto": "^1.0.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"embla-carousel-react": "^7.1.0",
"express": "^4.21.2",
"is-stream": "^4.0.1",
"make-dir": "^5.0.0",
"mongoose": "^8.12.1",
"node-ssh": "^13.2.0",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1",
"sb-promise-queue": "^2.1.1",
"sb-scandir": "^3.1.0",
"shell-escape": "^0.2.0",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1",
"wait-on": "^9.0.1",
"ws": "^8.18.3",
"zod": "^4.0.5"
"tailwindcss": "^4.0.8"
},
"devDependencies": {
"@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-swc": "^3.10.2",
"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",
"prettier": "3.6.2",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.5"
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"vite": "^6.0.5"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

-58128
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

-103
View File
@@ -1,103 +0,0 @@
#!/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
}
# Run main function
main "$@"
-121
View File
@@ -1,121 +0,0 @@
#!/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 "$@"
+767
View File
@@ -0,0 +1,767 @@
import { useState, useEffect, useRef } from "react";
import { NewTerminal } from "./apps/ssh/Terminal.jsx";
import { User } from "./apps/user/User.jsx";
import AddHostModal from "./modals/AddHostModal.jsx";
import LoginUserModal from "./modals/LoginUserModal.jsx";
import { Button } from "@mui/joy";
import { CssVarsProvider } from "@mui/joy";
import theme from "./theme";
import TabList from "./ui/TabList.jsx";
import Launchpad from "./apps/Launchpad.jsx";
import { Debounce } from './other/Utils.jsx';
import TermixIcon from "./images/termix_icon.png";
import RocketIcon from './images/launchpad_rocket.png';
import ProfileIcon from './images/profile_icon.png';
import CreateUserModal from "./modals/CreateUserModal.jsx";
import ProfileModal from "./modals/ProfileModal.jsx";
import ErrorModal from "./modals/ErrorModal.jsx";
import EditHostModal from "./modals/EditHostModal.jsx";
import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx";
function App() {
const [isAddHostHidden, setIsAddHostHidden] = useState(true);
const [isLoginUserHidden, setIsLoginUserHidden] = useState(true);
const [isCreateUserHidden, setIsCreateUserHidden] = useState(true);
const [isProfileHidden, setIsProfileHidden] = useState(true);
const [isErrorHidden, setIsErrorHidden] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [terminals, setTerminals] = useState([]);
const userRef = useRef(null);
const [activeTab, setActiveTab] = useState(null);
const [nextId, setNextId] = useState(1);
const [addHostForm, setAddHostForm] = useState({
name: "",
folder: "",
ip: "",
user: "",
password: "",
port: 22,
authMethod: "Select Auth",
rememberHost: false,
storePassword: true,
});
const [editHostForm, setEditHostForm] = useState({
name: "",
folder: "",
ip: "",
user: "",
password: "",
port: 22,
authMethod: "Select Auth",
rememberHost: true,
storePassword: true,
});
const [isNoAuthHidden, setIsNoAuthHidden] = useState(true);
const [authForm, setAuthForm] = useState({
password: "",
rsaKey: "",
});
const [loginUserForm, setLoginUserForm] = useState({
username: "",
password: "",
});
const [createUserForm, setCreateUserForm] = useState({
username: "",
password: "",
});
const [isLaunchpadOpen, setIsLaunchpadOpen] = useState(false);
const [splitTabIds, setSplitTabIds] = useState([]);
const [isEditHostHidden, setIsEditHostHidden] = useState(true);
const [currentHostConfig, setCurrentHostConfig] = useState(null);
const [isLoggingIn, setIsLoggingIn] = useState(true);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === "l") {
e.preventDefault();
setIsLaunchpadOpen((prev) => !prev);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
useEffect(() => {
terminals.forEach((terminal) => {
if (
(terminal.id === activeTab || splitTabIds.includes(terminal.id)) &&
terminal.terminalRef?.resizeTerminal
) {
terminal.terminalRef.resizeTerminal();
}
});
}, [splitTabIds, activeTab, terminals]);
useEffect(() => {
const handleResize = Debounce(() => {
terminals.forEach((terminal) => {
if (
(terminal.id === activeTab || splitTabIds.includes(terminal.id)) &&
terminal.terminalRef?.resizeTerminal
) {
terminal.terminalRef.resizeTerminal();
}
});
}, 100);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [splitTabIds, activeTab, terminals]);
useEffect(() => {
terminals.forEach((terminal) => {
if (
(terminal.id === activeTab || splitTabIds.includes(terminal.id)) &&
terminal.terminalRef?.resizeTerminal
) {
terminal.terminalRef.resizeTerminal();
}
});
}, [splitTabIds]);
useEffect(() => {
const sessionToken = localStorage.getItem('sessionToken');
let isComponentMounted = true;
let isLoginInProgress = false;
if (userRef.current?.getUser()) {
setIsLoggingIn(false);
setIsLoginUserHidden(true);
return;
}
if (!sessionToken) {
setIsLoggingIn(false);
setIsLoginUserHidden(false);
return;
}
setIsLoggingIn(true);
let loginAttempts = 0;
const maxAttempts = 50;
let attemptLoginInterval;
const loginTimeout = setTimeout(() => {
if (isComponentMounted) {
clearInterval(attemptLoginInterval);
if (!userRef.current?.getUser()) {
localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false);
setIsLoggingIn(false);
setErrorMessage('Login timed out. Please try again.');
setIsErrorHidden(false);
}
}
}, 10000);
const attemptLogin = () => {
if (!isComponentMounted || isLoginInProgress) return;
if (loginAttempts >= maxAttempts || userRef.current?.getUser()) {
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
if (!userRef.current?.getUser()) {
localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false);
setIsLoggingIn(false);
setErrorMessage('Login timed out. Please try again.');
setIsErrorHidden(false);
}
return;
}
if (userRef.current) {
isLoginInProgress = true;
userRef.current.loginUser({
sessionToken,
onSuccess: () => {
if (isComponentMounted) {
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
setIsLoginUserHidden(true);
setIsLoggingIn(false);
setIsErrorHidden(true);
}
isLoginInProgress = false;
},
onFailure: (error) => {
if (isComponentMounted) {
if (!userRef.current?.getUser()) {
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
localStorage.removeItem('sessionToken');
setErrorMessage(`Auto-login failed: ${error}`);
setIsErrorHidden(false);
setIsLoginUserHidden(false);
setIsLoggingIn(false);
}
}
isLoginInProgress = false;
},
});
}
loginAttempts++;
};
attemptLoginInterval = setInterval(attemptLogin, 100);
attemptLogin();
return () => {
isComponentMounted = false;
clearTimeout(loginTimeout);
clearInterval(attemptLoginInterval);
};
}, []);
const handleAddHost = () => {
if (addHostForm.ip && addHostForm.user && addHostForm.port) {
if (!addHostForm.rememberHost) {
connectToHost();
setIsAddHostHidden(true);
return;
}
if (addHostForm.authMethod === 'Select Auth') {
alert("Please select an authentication method.");
return;
}
if (addHostForm.authMethod === 'password' && !addHostForm.password) {
setIsNoAuthHidden(false);
return;
}
if (addHostForm.authMethod === 'rsaKey' && !addHostForm.rsaKey) {
setIsNoAuthHidden(false);
return;
}
connectToHost();
if (!addHostForm.storePassword) {
addHostForm.password = '';
}
handleSaveHost();
setIsAddHostHidden(true);
} else {
alert("Please fill out all required fields (IP, User, Port).");
}
};
const connectToHost = () => {
const hostConfig = {
name: addHostForm.name || '',
folder: addHostForm.folder || '',
ip: addHostForm.ip,
user: addHostForm.user,
port: String(addHostForm.port),
password: addHostForm.rememberHost && addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
rsaKey: addHostForm.rememberHost && addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
};
const newTerminal = {
id: nextId,
title: hostConfig.name || hostConfig.ip,
hostConfig,
terminalRef: null,
};
setTerminals([...terminals, newTerminal]);
setActiveTab(nextId);
setNextId(nextId + 1);
setIsAddHostHidden(true);
setAddHostForm({ name: "", folder: "", ip: "", user: "", password: "", rsaKey: "", port: 22, authMethod: "Select Auth", rememberHost: false, storePassword: true });
}
const handleAuthSubmit = (form) => {
const updatedTerminals = terminals.map((terminal) => {
if (terminal.id === activeTab) {
return {
...terminal,
hostConfig: {
...terminal.hostConfig,
password: form.password,
rsaKey: form.rsaKey
}
};
}
return terminal;
});
setTerminals(updatedTerminals);
setIsNoAuthHidden(true);
};
const connectToHostWithConfig = (hostConfig) => {
if (!hostConfig || typeof hostConfig !== 'object') {
return;
}
if (!hostConfig.ip || !hostConfig.user) {
return;
}
const cleanHostConfig = {
name: hostConfig.name || '',
folder: hostConfig.folder || '',
ip: hostConfig.ip.trim(),
user: hostConfig.user.trim(),
port: hostConfig.port || '22',
password: hostConfig.password?.trim(),
rsaKey: hostConfig.rsaKey?.trim(),
};
const newTerminal = {
id: nextId,
title: cleanHostConfig.name || cleanHostConfig.ip,
hostConfig: cleanHostConfig,
terminalRef: null,
};
setTerminals([...terminals, newTerminal]);
setActiveTab(nextId);
setNextId(nextId + 1);
setIsLaunchpadOpen(false);
}
const handleSaveHost = () => {
let hostConfig = {
name: addHostForm.name || addHostForm.ip,
folder: addHostForm.folder,
ip: addHostForm.ip,
user: addHostForm.user,
password: addHostForm.authMethod === 'password' ? addHostForm.password : undefined,
rsaKey: addHostForm.authMethod === 'rsaKey' ? addHostForm.rsaKey : undefined,
port: String(addHostForm.port),
}
if (userRef.current) {
userRef.current.saveHost({
hostConfig,
});
}
}
const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => {
if (userRef.current) {
if (sessionToken) {
userRef.current.loginUser({
sessionToken,
onSuccess: () => {
setIsLoginUserHidden(true);
setIsLoggingIn(false);
if (onSuccess) onSuccess();
},
onFailure: (error) => {
localStorage.removeItem('sessionToken');
setIsLoginUserHidden(false);
setIsLoggingIn(false);
if (onFailure) onFailure(error);
},
});
} else {
userRef.current.loginUser({
username,
password,
onSuccess: () => {
setIsLoginUserHidden(true);
setIsLoggingIn(false);
if (onSuccess) onSuccess();
},
onFailure: (error) => {
setIsLoginUserHidden(false);
setIsLoggingIn(false);
if (onFailure) onFailure(error);
},
});
}
}
};
const handleGuestLogin = () => {
if (userRef.current) {
userRef.current.loginAsGuest();
}
}
const handleCreateUser = ({ username, password, onSuccess, onFailure }) => {
if (userRef.current) {
userRef.current.createUser({
username,
password,
onSuccess,
onFailure,
});
}
};
const handleDeleteUser = ({ onSuccess, onFailure }) => {
if (userRef.current) {
userRef.current.deleteUser({
onSuccess,
onFailure,
});
}
};
const handleLogoutUser = () => {
if (userRef.current) {
userRef.current.logoutUser();
window.location.reload();
}
};
const getUser = () => {
if (userRef.current) {
return userRef.current.getUser();
}
}
const getHosts = () => {
if (userRef.current) {
return userRef.current.getAllHosts();
}
}
const deleteHost = (hostConfig) => {
if (userRef.current) {
userRef.current.deleteHost({
hostId: hostConfig._id,
});
}
};
const updateEditHostForm = (hostConfig) => {
if (hostConfig) {
setCurrentHostConfig(hostConfig);
setIsEditHostHidden(false);
} else {
console.error("hostConfig is null");
}
};
const handleEditHost = async (oldConfig, newConfig = null) => {
try {
if (newConfig) {
if (isEditing) return;
setIsEditing(true);
try {
await userRef.current.editHost({
oldHostConfig: oldConfig,
newHostConfig: newConfig,
});
await new Promise(resolve => setTimeout(resolve, 3000));
} finally {
setIsEditing(false);
setIsEditHostHidden(true);
}
return;
}
updateEditHostForm(oldConfig);
} catch (error) {
console.error('Edit failed:', error);
setErrorMessage(`Edit failed: ${error}`);
setIsErrorHidden(false);
setIsEditing(false);
}
};
const closeTab = (id) => {
const newTerminals = terminals.filter((t) => t.id !== id);
setTerminals(newTerminals);
if (activeTab === id) {
setActiveTab(newTerminals[0]?.id || null);
}
};
const toggleSplit = (id) => {
if (splitTabIds.includes(id)) {
setSplitTabIds((prev) => prev.filter((splitId) => splitId !== id));
return;
}
if (splitTabIds.length >= 3) return;
setSplitTabIds((prev) =>
prev.includes(id) ? prev.filter((splitId) => splitId !== id) : [...prev, id]
);
};
const handleSetActiveTab = (tabId) => {
setActiveTab(tabId);
};
const getLayoutStyle = () => {
if (splitTabIds.length === 1) {
return "grid grid-cols-2 h-full gap-4";
} else if (splitTabIds.length > 1) {
return "grid grid-cols-2 grid-rows-2 gap-4 h-full overflow-hidden";
}
return "flex flex-col h-full gap-4";
};
return (
<CssVarsProvider theme={theme}>
<div className="flex h-screen bg-neutral-900 overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden">
{/* Topbar */}
<div className="bg-neutral-800 text-white p-4 flex items-center justify-between gap-4 min-h-[75px] max-h-[75px] shadow-xl border-b-5 border-neutral-700">
<div className="bg-neutral-700 flex justify-center items-center gap-1 p-2 rounded-lg h-[52px]">
<img src={TermixIcon} alt="Termix Icon" className="w-[25px] h-[25px] object-contain" />
<h2 className="text-lg font-bold">Termix</h2>
</div>
<div className="flex-1 bg-neutral-700 rounded-lg overflow-hidden h-[52px] flex items-center">
<div className="flex-1 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-neutral-500 scrollbar-track-neutral-700 h-[52px] scrollbar-thumb-rounded-full scrollbar-track-rounded-full scrollbar-h-1">
<TabList
terminals={terminals}
activeTab={activeTab}
setActiveTab={handleSetActiveTab}
closeTab={closeTab}
toggleSplit={toggleSplit}
splitTabIds={splitTabIds}
theme={theme}
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-4">
{/* Launchpad Button */}
<Button
disabled={isLoggingIn || !userRef.current?.getUser()}
onClick={() => setIsLaunchpadOpen(true)}
sx={{
backgroundColor: theme.palette.general.tertiary,
"&:hover": { backgroundColor: theme.palette.general.secondary },
flexShrink: 0,
height: "52px",
width: "52px",
padding: 0,
opacity: (!userRef.current?.getUser() || isLoggingIn) ? 0.3 : 1,
cursor: (!userRef.current?.getUser() || isLoggingIn) ? 'not-allowed' : 'pointer',
"&:disabled": {
opacity: 0.3,
backgroundColor: theme.palette.general.tertiary,
}
}}
>
<img src={RocketIcon} alt="Launchpad" style={{ width: "70%", height: "70%", objectFit: "contain" }} />
</Button>
{/* Add Host Button */}
<Button
disabled={isLoggingIn || !userRef.current?.getUser()}
onClick={() => setIsAddHostHidden(false)}
sx={{
backgroundColor: theme.palette.general.tertiary,
"&:hover": { backgroundColor: theme.palette.general.secondary },
flexShrink: 0,
height: "52px",
width: "52px",
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 0,
opacity: (!userRef.current?.getUser() || isLoggingIn) ? 0.3 : 1,
cursor: (!userRef.current?.getUser() || isLoggingIn) ? 'not-allowed' : 'pointer',
"&:disabled": {
opacity: 0.3,
backgroundColor: theme.palette.general.tertiary,
},
fontSize: "4rem",
fontWeight: "600",
lineHeight: "0",
paddingBottom: "8px",
}}
>
+
</Button>
{/* Profile Button */}
<Button
disabled={isLoggingIn}
onClick={() => userRef.current?.getUser() ? setIsProfileHidden(false) : setIsLoginUserHidden(false)}
sx={{
backgroundColor: theme.palette.general.tertiary,
"&:hover": { backgroundColor: theme.palette.general.secondary },
flexShrink: 0,
height: "52px",
width: "52px",
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 0,
opacity: isLoggingIn ? 0.3 : 1,
cursor: isLoggingIn ? 'not-allowed' : 'pointer',
"&:disabled": {
opacity: 0.3,
backgroundColor: theme.palette.general.tertiary,
}
}}
>
<img
src={ProfileIcon}
alt="Profile"
style={{ width: "70%", height: "70%", objectFit: "contain" }}
/>
</Button>
</div>
</div>
{/* Terminal Views */}
<div className={`relative p-4 terminal-container ${getLayoutStyle()}`}>
{userRef.current?.getUser() ? (
terminals.map((terminal) => (
<div
key={terminal.id}
className={`bg-neutral-800 rounded-lg overflow-hidden shadow-xl border-5 border-neutral-700 ${
splitTabIds.includes(terminal.id) || activeTab === terminal.id ? "block" : "hidden"
} flex-1`}
style={{
order: splitTabIds.includes(terminal.id)
? splitTabIds.indexOf(terminal.id)
: 0,
}}
>
<NewTerminal
key={terminal.id}
hostConfig={terminal.hostConfig}
isVisible={activeTab === terminal.id || splitTabIds.includes(terminal.id)}
setIsNoAuthHidden={setIsNoAuthHidden}
ref={(ref) => {
terminal.terminalRef = ref;
}}
/>
</div>
))
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center text-neutral-400">
<h2 className="text-2xl font-bold mb-4">Welcome to Termix</h2>
<p>{isLoggingIn ? "Checking login status..." : "Please login to start managing your SSH connections"}</p>
</div>
</div>
)}
<NoAuthenticationModal
isHidden={isNoAuthHidden}
form={authForm}
setForm={setAuthForm}
setIsNoAuthHidden={setIsNoAuthHidden}
handleAuthSubmit={handleAuthSubmit}
/>
</div>
{/* Modals */}
{userRef.current?.getUser() && (
<>
<AddHostModal
isHidden={isAddHostHidden}
form={addHostForm}
setForm={setAddHostForm}
handleAddHost={handleAddHost}
setIsAddHostHidden={setIsAddHostHidden}
/>
<EditHostModal
isHidden={isEditHostHidden}
form={editHostForm}
setForm={setEditHostForm}
handleEditHost={handleEditHost}
setIsEditHostHidden={setIsEditHostHidden}
hostConfig={currentHostConfig}
/>
<ProfileModal
isHidden={isProfileHidden}
getUser={getUser}
handleDeleteUser={handleDeleteUser}
handleLogoutUser={handleLogoutUser}
setIsProfileHidden={setIsProfileHidden}
/>
{isLaunchpadOpen && (
<Launchpad
onClose={() => setIsLaunchpadOpen(false)}
getHosts={getHosts}
connectToHost={connectToHostWithConfig}
isAddHostHidden={isAddHostHidden}
setIsAddHostHidden={setIsAddHostHidden}
isEditHostHidden={isEditHostHidden}
isErrorHidden={isErrorHidden}
deleteHost={deleteHost}
editHost={handleEditHost}
shareHost={(hostId, username) => userRef.current?.shareHost(hostId, username)}
userRef={userRef}
/>
)}
</>
)}
<ErrorModal
isHidden={isErrorHidden}
errorMessage={errorMessage}
setIsErrorHidden={setIsErrorHidden}
/>
<LoginUserModal
isHidden={isLoginUserHidden}
form={loginUserForm}
setForm={setLoginUserForm}
handleLoginUser={handleLoginUser}
handleGuestLogin={handleGuestLogin}
setIsLoginUserHidden={setIsLoginUserHidden}
setIsCreateUserHidden={setIsCreateUserHidden}
/>
<CreateUserModal
isHidden={isCreateUserHidden}
form={createUserForm}
setForm={setCreateUserForm}
handleCreateUser={handleCreateUser}
setIsCreateUserHidden={setIsCreateUserHidden}
setIsLoginUserHidden={setIsLoginUserHidden}
/>
{/* User component */}
<User
ref={userRef}
onLoginSuccess={() => {
setIsLoginUserHidden(true);
setIsLoggingIn(false);
setIsErrorHidden(true);
}}
onCreateSuccess={() => {
setIsCreateUserHidden(true);
handleLoginUser({
username: createUserForm.username,
password: createUserForm.password,
onSuccess: () => {
setIsLoginUserHidden(true);
setIsLoggingIn(false);
setIsErrorHidden(true);
},
onFailure: (error) => {
setErrorMessage(`Login failed: ${error}`);
setIsErrorHidden(false);
}
});
}}
onDeleteSuccess={() => {
setIsProfileHidden(true);
window.location.reload();
}}
onFailure={(error) => {
setErrorMessage(`Action failed: ${error}`);
setIsErrorHidden(false);
setIsLoggingIn(false);
}}
/>
</div>
</div>
</CssVarsProvider>
);
}
export default App;
+216
View File
@@ -0,0 +1,216 @@
import PropTypes from 'prop-types';
import { useEffect, useRef, useState } from 'react';
import { CssVarsProvider } from '@mui/joy/styles';
import { Button } from '@mui/joy';
import HostViewerIcon from '../images/host_viewer_icon.png';
import theme from '../theme.js';
import HostViewer from './ssh/HostViewer.jsx';
function Launchpad({
onClose,
getHosts,
connectToHost,
isAddHostHidden,
setIsAddHostHidden,
isEditHostHidden,
isErrorHidden,
deleteHost,
editHost,
shareHost,
userRef,
}) {
const launchpadRef = useRef(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [activeApp, setActiveApp] = useState('hostViewer');
const [isAnyModalOpen, setIsAnyModalOpen] = useState(false);
useEffect(() => {
const handleClickOutside = (event) => {
if (
launchpadRef.current &&
!launchpadRef.current.contains(event.target) &&
isAddHostHidden &&
isEditHostHidden &&
isErrorHidden &&
!isAnyModalOpen
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isAnyModalOpen]);
const handleModalOpen = () => {
setIsAnyModalOpen(true);
};
const handleModalClose = () => {
setIsAnyModalOpen(false);
};
return (
<CssVarsProvider theme={theme}>
<div
style={{
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.2)",
zIndex: 1000,
backdropFilter: "blur(5px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
ref={launchpadRef}
style={{
width: "75%",
height: "75%",
backgroundColor: theme.palette.general.tertiary,
display: "flex",
borderRadius: "8px",
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.3)",
border: `1px solid ${theme.palette.general.secondary}`,
color: theme.palette.text.primary,
padding: 0,
}}
>
{/* Sidebar */}
<div
style={{
width: sidebarOpen ? "200px" : "60px",
backgroundColor: theme.palette.general.disabled,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
padding: "10px 5px",
transition: "width 0.3s ease",
overflow: "hidden",
borderRight: `1px solid ${theme.palette.general.secondary}`,
borderTopLeftRadius: "8px",
borderBottomLeftRadius: "8px",
}}
>
{/* Sidebar Toggle Button */}
<Button
onClick={() => setSidebarOpen(!sidebarOpen)}
sx={{
backgroundColor: theme.palette.general.primary,
'&:hover': {
backgroundColor: theme.palette.general.dark,
},
}}
style={{
width: sidebarOpen ? "175px" : "40px",
height: "40px",
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "10px",
transition: "width 0.3s ease",
}}
>
{sidebarOpen ? "←" : "→"}
</Button>
{/* HostViewer Button */}
<Button
onClick={() => setActiveApp('hostViewer')}
sx={{
backgroundColor: activeApp === 'hostViewer'
? theme.palette.general.tertiary
: theme.palette.general.primary,
'&:hover': {
backgroundColor: activeApp === 'hostViewer'
? theme.palette.general.tertiary
: theme.palette.general.dark,
},
}}
style={{
width: sidebarOpen ? "175px" : "40px",
height: "40px",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "8px",
paddingLeft: sidebarOpen ? "15px" : "0",
transition: "width 0.3s ease",
}}
>
{sidebarOpen ? (
"Hosts"
) : (
<img
src={HostViewerIcon}
alt="Host Viewer"
width={24}
height={24}
style={{
objectFit: "contain",
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}
/>
)}
</Button>
</div>
{/* Main Content */}
<div style={{ flex: 1, overflow: 'hidden' }}>
{activeApp === 'hostViewer' && (
<HostViewer
getHosts={getHosts}
connectToHost={(hostConfig) => {
if (!hostConfig || typeof hostConfig !== 'object') {
return;
}
if (!hostConfig.ip || !hostConfig.user) {
return;
}
connectToHost(hostConfig);
}}
setIsAddHostHidden={setIsAddHostHidden}
deleteHost={deleteHost}
editHost={editHost}
openEditPanel={editHost}
shareHost={shareHost}
onModalOpen={handleModalOpen}
onModalClose={handleModalClose}
userRef={userRef}
/>
)}
</div>
</div>
</div>
</CssVarsProvider>
);
}
Launchpad.propTypes = {
onClose: PropTypes.func.isRequired,
getHosts: PropTypes.func.isRequired,
connectToHost: PropTypes.func.isRequired,
isAddHostHidden: PropTypes.bool.isRequired,
setIsAddHostHidden: PropTypes.func.isRequired,
isEditHostHidden: PropTypes.bool.isRequired,
isErrorHidden: PropTypes.bool.isRequired,
deleteHost: PropTypes.func.isRequired,
editHost: PropTypes.func.isRequired,
shareHost: PropTypes.func.isRequired,
userRef: PropTypes.object.isRequired,
};
export default Launchpad;
+423
View File
@@ -0,0 +1,423 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { Button, Input } from "@mui/joy";
import ShareHostModal from "../../modals/ShareHostModal";
function HostViewer({ getHosts, connectToHost, setIsAddHostHidden, deleteHost, editHost, openEditPanel, shareHost, onModalOpen, onModalClose, userRef }) {
const [hosts, setHosts] = useState([]);
const [filteredHosts, setFilteredHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [collapsedFolders, setCollapsedFolders] = useState(new Set());
const [draggedHost, setDraggedHost] = useState(null);
const [isDraggingOver, setIsDraggingOver] = useState(null);
const isMounted = useRef(true);
const [isDeleting, setIsDeleting] = useState(false);
const [isShareModalHidden, setIsShareModalHidden] = useState(true);
const [selectedHostForShare, setSelectedHostForShare] = useState(null);
const fetchHosts = async () => {
try {
const savedHosts = await getHosts();
if (isMounted.current) {
setHosts(savedHosts || []);
setFilteredHosts(savedHosts || []);
setIsLoading(false);
}
} catch (error) {
console.error("Host fetch failed:", error);
if (isMounted.current) {
setHosts([]);
setFilteredHosts([]);
setIsLoading(false);
}
}
};
useEffect(() => {
isMounted.current = true;
fetchHosts();
const intervalId = setInterval(() => {
fetchHosts();
}, 2000);
return () => {
isMounted.current = false;
clearInterval(intervalId);
};
}, []);
useEffect(() => {
const filtered = hosts.filter((hostWrapper) => {
const hostConfig = hostWrapper.config || {};
return hostConfig.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hostConfig.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
hostConfig.folder?.toLowerCase().includes(searchTerm.toLowerCase());
});
setFilteredHosts(filtered);
}, [searchTerm, hosts]);
useEffect(() => {
if (!isShareModalHidden) {
onModalOpen();
} else {
onModalClose();
}
}, [isShareModalHidden, onModalOpen, onModalClose]);
const toggleFolder = (folderName) => {
setCollapsedFolders(prev => {
const newSet = new Set(prev);
if (newSet.has(folderName)) {
newSet.delete(folderName);
} else {
newSet.add(folderName);
}
return newSet;
});
};
const groupHostsByFolder = (hosts) => {
const grouped = {};
const noFolder = [];
const sortedHosts = [...hosts].sort((a, b) => {
const nameA = (a.config?.name || a.config?.ip || '').toLowerCase();
const nameB = (b.config?.name || b.config?.ip || '').toLowerCase();
return nameA.localeCompare(nameB);
});
sortedHosts.forEach(host => {
const folder = host.config?.folder;
if (folder) {
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(host);
} else {
noFolder.push(host);
}
});
const sortedFolders = Object.keys(grouped).sort((a, b) => a.localeCompare(b));
return { grouped, sortedFolders, noFolder };
};
const handleDragStart = (e, host) => {
setDraggedHost(host);
e.dataTransfer.setData('text/plain', '');
};
const handleDragOver = (e, folderName) => {
e.preventDefault();
setIsDraggingOver(folderName);
};
const handleDragLeave = () => {
setIsDraggingOver(null);
};
const handleDrop = async (e, targetFolder) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(null);
if (!draggedHost) return;
if (draggedHost.config.folder === targetFolder) return;
const newConfig = {
...draggedHost.config,
folder: targetFolder
};
try {
await editHost(draggedHost.config, newConfig);
await fetchHosts();
} catch (error) {
console.error('Failed to update folder:', error);
}
setDraggedHost(null);
};
const handleDropOnNoFolder = async (e) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(null);
if (!draggedHost || !draggedHost.config.folder) return;
const newConfig = {
...draggedHost.config,
folder: null
};
try {
await editHost(draggedHost.config, newConfig);
await fetchHosts();
} catch (error) {
console.error('Failed to remove from folder:', error);
}
setDraggedHost(null);
};
const handleDelete = async (e, hostWrapper) => {
e.stopPropagation();
if (isDeleting) return;
setIsDeleting(true);
try {
const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id;
if (isOwner) {
await deleteHost({ _id: hostWrapper._id });
} else {
await userRef.current.removeShare(hostWrapper._id);
}
await new Promise(resolve => setTimeout(resolve, 500));
await fetchHosts();
} catch (error) {
console.error('Failed to delete/remove host:', error);
} finally {
setIsDeleting(false);
}
};
const handleShare = async (hostId, username) => {
try {
await shareHost(hostId, username);
await fetchHosts();
} catch (error) {
console.error('Failed to share host:', error);
}
};
const renderHostItem = (hostWrapper) => {
const hostConfig = hostWrapper.config || {};
const isOwner = hostWrapper.createdBy?._id === userRef.current?.getUser()?.id;
if (!hostConfig) {
return null;
}
return (
<div
key={hostWrapper._id}
className={`flex justify-between items-center bg-neutral-800 p-3 rounded-lg shadow-md border border-neutral-700 w-full cursor-grab active:cursor-grabbing hover:border-neutral-500 transition-colors ${draggedHost === hostWrapper ? 'opacity-50' : ''}`}
draggable={isOwner}
onDragStart={(e) => isOwner && handleDragStart(e, hostWrapper)}
onDragEnd={() => setDraggedHost(null)}
>
<div className="flex items-center gap-2 flex-1">
<div className="text-neutral-500 cursor-grab active:cursor-grabbing"></div>
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">{hostConfig.name || hostConfig.ip}</p>
{!isOwner && (
<span className="text-xs bg-neutral-700 text-neutral-300 px-2 py-1 rounded">
Shared by {hostWrapper.createdBy?.username}
</span>
)}
</div>
<p className="text-sm text-gray-400">
{hostConfig.user ? `${hostConfig.user}@${hostConfig.ip}` : `${hostConfig.ip}:${hostConfig.port}`}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
if (!hostWrapper.config || !hostWrapper.config.ip || !hostWrapper.config.user) {
return;
}
connectToHost(hostWrapper.config);
}}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
Connect
</Button>
{isOwner && (
<>
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
setSelectedHostForShare(hostWrapper);
setIsShareModalHidden(false);
}}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
Share
</Button>
<Button
className="text-black"
onClick={(e) => handleDelete(e, hostWrapper)}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
<Button
className="text-black"
onClick={(e) => {
e.stopPropagation();
openEditPanel(hostConfig);
}}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
Edit
</Button>
</>
)}
{!isOwner && (
<Button
className="text-black"
onClick={(e) => handleDelete(e, hostWrapper)}
disabled={isDeleting}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" },
opacity: isDeleting ? 0.5 : 1,
cursor: isDeleting ? "not-allowed" : "pointer"
}}
>
{isDeleting ? "Removing..." : "Remove Share"}
</Button>
)}
</div>
</div>
);
};
return (
<div className="h-full w-full p-4 text-white flex flex-col">
<div className="flex items-center justify-between mb-2 w-full gap-2">
<Input
placeholder="Search hosts..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
sx={{
flex: 1,
backgroundColor: "#6e6e6e",
color: "#fff",
"&::placeholder": { color: "#ccc" },
}}
/>
<Button
className="text-black"
onClick={() => setIsAddHostHidden(false)}
sx={{
backgroundColor: "#6e6e6e",
"&:hover": { backgroundColor: "#0f0f0f" }
}}
>
Add Host
</Button>
</div>
<div className="flex-grow overflow-auto">
{isLoading ? (
<p className="text-gray-300">Loading hosts...</p>
) : filteredHosts.length > 0 ? (
<div className="flex flex-col gap-2 w-full">
{(() => {
const { grouped, sortedFolders, noFolder } = groupHostsByFolder(filteredHosts);
return (
<>
{/* Render hosts without folders first */}
<div
className={`flex flex-col gap-2 p-2 rounded-lg transition-colors ${isDraggingOver === 'no-folder' ? 'bg-neutral-700' : ''}`}
onDragOver={(e) => handleDragOver(e, 'no-folder')}
onDragLeave={handleDragLeave}
onDrop={handleDropOnNoFolder}
>
{noFolder.map((host) => renderHostItem(host))}
</div>
{/* Render folders and their hosts */}
{sortedFolders.map((folderName) => (
<div key={folderName} className="mb-2">
<div
className={`flex items-center gap-2 p-2 bg-neutral-600 rounded-lg cursor-pointer hover:bg-neutral-500 transition-colors ${
isDraggingOver === folderName ? 'bg-neutral-500 border-2 border-dashed border-neutral-400' : ''
}`}
onClick={() => toggleFolder(folderName)}
onDragOver={(e) => handleDragOver(e, folderName)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, folderName)}
>
<span className={`font-bold w-4 text-center transition-transform ${collapsedFolders.has(folderName) ? 'rotate-[-90deg]' : ''}`}>
</span>
<span className="font-bold">{folderName}</span>
<span className="text-sm text-gray-300">
({grouped[folderName].length})
</span>
</div>
{!collapsedFolders.has(folderName) && (
<div className="ml-6 mt-2 flex flex-col gap-2">
{grouped[folderName].map((host) => renderHostItem(host))}
</div>
)}
</div>
))}
</>
);
})()}
</div>
) : (
<p className="text-gray-300">No hosts available...</p>
)}
</div>
<ShareHostModal
isHidden={isShareModalHidden}
setIsHidden={setIsShareModalHidden}
handleShare={handleShare}
hostConfig={selectedHostForShare}
/>
</div>
);
}
HostViewer.propTypes = {
getHosts: PropTypes.func.isRequired,
connectToHost: PropTypes.func.isRequired,
setIsAddHostHidden: PropTypes.func.isRequired,
deleteHost: PropTypes.func.isRequired,
editHost: PropTypes.func.isRequired,
openEditPanel: PropTypes.func.isRequired,
shareHost: PropTypes.func.isRequired,
onModalOpen: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
userRef: PropTypes.object.isRequired,
};
export default HostViewer;
+243
View File
@@ -0,0 +1,243 @@
import { forwardRef, useImperativeHandle, useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import io from "socket.io-client";
import PropTypes from "prop-types";
import theme from "../../theme.js";
export const NewTerminal = forwardRef(({ hostConfig, isVisible, setIsNoAuthHidden }, ref) => {
const terminalRef = useRef(null);
const socketRef = useRef(null);
const fitAddon = useRef(new FitAddon());
const terminalInstance = useRef(null);
const resizeTerminal = () => {
const terminalContainer = terminalRef.current;
const parentContainer = terminalContainer?.parentElement;
if (!parentContainer || parentContainer.clientWidth === 0) return;
const parentWidth = parentContainer.clientWidth - 10;
const parentHeight = parentContainer.clientHeight - 10;
terminalContainer.style.width = `${parentWidth}px`;
terminalContainer.style.height = `${parentHeight}px`;
requestAnimationFrame(() => {
fitAddon.current.fit();
if (socketRef.current && terminalInstance.current) {
const { cols, rows } = terminalInstance.current;
socketRef.current.emit("resize", { cols, rows });
}
});
};
useImperativeHandle(ref, () => ({
resizeTerminal: resizeTerminal,
}));
useEffect(() => {
if (!hostConfig || !terminalRef.current) return;
terminalInstance.current = new Terminal({
cursorBlink: true,
theme: {
background: theme.palette.background.terminal,
foreground: theme.palette.text.primary,
cursor: theme.palette.text.primary,
},
fontSize: 14,
scrollback: 1000,
ignoreBracketedPasteMode: true,
});
terminalInstance.current.loadAddon(fitAddon.current);
terminalInstance.current.open(terminalRef.current);
const socket = io(
window.location.hostname === "localhost"
? "http://localhost:8081"
: "/",
{
path: "/ssh.io/socket.io",
transports: ["websocket", "polling"],
}
);
socketRef.current = socket;
socket.on("connect_error", (error) => {
terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`);
});
socket.on("connect_timeout", () => {
terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`);
});
socket.on("error", (err) => {
const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth");
if (isAuthError && !hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
authModalShown = true;
setIsNoAuthHidden(false);
}
terminalInstance.current.write(`\r\n*** Error: ${err} ***\r\n`);
});
socket.on("connect", () => {
fitAddon.current.fit();
resizeTerminal();
const { cols, rows } = terminalInstance.current;
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim()) {
setIsNoAuthHidden(false);
return;
}
const sshConfig = {
ip: hostConfig.ip,
user: hostConfig.user,
port: Number(hostConfig.port) || 22,
password: hostConfig.password?.trim(),
rsaKey: hostConfig.rsaKey?.trim()
};
socket.emit("connectToHost", cols, rows, sshConfig);
});
setTimeout(() => {
fitAddon.current.fit();
resizeTerminal();
terminalInstance.current.focus();
}, 50);
socket.on("data", (data) => {
const decoder = new TextDecoder("utf-8");
terminalInstance.current.write(decoder.decode(new Uint8Array(data)));
});
let isPasting = false;
terminalInstance.current.onData((data) => {
socketRef.current.emit("data", data);
});
terminalInstance.current.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "v") {
if (isPasting) return false;
isPasting = true;
event.preventDefault();
navigator.clipboard.readText().then((text) => {
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = text.split("\n");
if (socketRef.current) {
let index = 0;
const sendLine = () => {
if (index < lines.length) {
socketRef.current.emit("data", lines[index] + "\r");
index++;
setTimeout(sendLine, 10);
} else {
isPasting = false;
}
};
sendLine();
} else {
isPasting = false;
}
}).catch((err) => {
console.error("Failed to read clipboard contents:", err);
isPasting = false;
});
return false;
}
return true;
});
terminalInstance.current.onKey(({ domEvent }) => {
if (domEvent.key === "c" && (domEvent.ctrlKey || domEvent.metaKey)) {
const selection = terminalInstance.current.getSelection();
if (selection) {
navigator.clipboard.writeText(selection);
}
}
});
let authModalShown = false;
socket.on("noAuthRequired", () => {
if (!hostConfig.password?.trim() && !hostConfig.rsaKey?.trim() && !authModalShown) {
authModalShown = true;
setIsNoAuthHidden(false);
}
});
return () => {
if (terminalInstance.current) {
terminalInstance.current.dispose();
terminalInstance.current = null;
}
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
authModalShown = false;
};
}, [hostConfig]);
useEffect(() => {
resizeTerminal();
}, [isVisible]);
useEffect(() => {
const terminalContainer = terminalRef.current;
if (!terminalContainer) return;
const parentContainer = terminalContainer.parentElement;
if (!parentContainer) return;
const observer = new ResizeObserver(() => {
resizeTerminal();
});
observer.observe(parentContainer);
return () => {
observer.disconnect();
};
}, []);
return (
<div
ref={terminalRef}
className="w-full h-full overflow-hidden text-left"
style={{
visibility: isVisible ? 'visible' : 'hidden',
position: 'absolute',
width: '100%',
height: '100%',
transform: 'translateY(5px) translateX(5px)',
}}
/>
);
});
NewTerminal.displayName = "NewTerminal";
NewTerminal.propTypes = {
hostConfig: PropTypes.shape({
ip: PropTypes.string.isRequired,
user: PropTypes.string.isRequired,
password: PropTypes.string,
rsaKey: PropTypes.string,
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
}).isRequired,
isVisible: PropTypes.bool.isRequired,
setIsNoAuthHidden: PropTypes.func.isRequired,
};
+309
View File
@@ -0,0 +1,309 @@
import { useRef, forwardRef, useImperativeHandle, useEffect } from "react";
import io from "socket.io-client";
import PropTypes from "prop-types";
const SOCKET_URL = window.location.hostname === "localhost"
? "http://localhost:8082/database.io"
: "/database.io";
const socket = io(SOCKET_URL, {
path: "/database.io/socket.io",
transports: ["websocket", "polling"],
autoConnect: false,
});
export const User = forwardRef(({ onLoginSuccess, onCreateSuccess, onDeleteSuccess, onFailure }, ref) => {
const socketRef = useRef(socket);
const currentUser = useRef(null);
useEffect(() => {
socketRef.current.connect();
return () => socketRef.current.disconnect();
}, []);
useEffect(() => {
const verifySession = async () => {
const storedSession = localStorage.getItem("sessionToken");
if (!storedSession || storedSession === "undefined") return;
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("verifySession", { sessionToken: storedSession }, resolve);
});
if (response?.success) {
currentUser.current = {
id: response.user.id,
username: response.user.username,
sessionToken: storedSession,
};
onLoginSuccess(response.user);
} else {
localStorage.removeItem("sessionToken");
onFailure("Session expired");
}
} catch (error) {
onFailure(error.message);
}
};
verifySession();
}, []);
const createUser = async (userConfig) => {
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("createUser", userConfig, resolve);
});
if (response?.user?.sessionToken) {
currentUser.current = {
id: response.user.id,
username: response.user.username,
sessionToken: response.user.sessionToken,
};
localStorage.setItem("sessionToken", response.user.sessionToken);
onCreateSuccess(response.user);
} else {
throw new Error(response?.error || "User creation failed");
}
} catch (error) {
onFailure(error.message);
}
};
const loginUser = async ({ username, password, sessionToken }) => {
try {
const response = await new Promise((resolve) => {
const credentials = sessionToken ? { sessionToken } : { username, password };
socketRef.current.emit("loginUser", credentials, resolve);
});
if (response?.success) {
currentUser.current = {
id: response.user.id,
username: response.user.username,
sessionToken: response.user.sessionToken,
};
localStorage.setItem("sessionToken", response.user.sessionToken);
onLoginSuccess(response.user);
} else {
throw new Error(response?.error || "Login failed");
}
} catch (error) {
onFailure(error.message);
}
};
const loginAsGuest = async () => {
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("loginAsGuest", resolve);
});
if (response?.success) {
currentUser.current = {
id: response.user.id,
username: response.user.username,
sessionToken: response.user.sessionToken,
};
localStorage.setItem("sessionToken", response.user.sessionToken);
onLoginSuccess(response.user);
} else {
throw new Error(response?.error || "Guest login failed");
}
} catch (error) {
onFailure(error.message);
}
}
const logoutUser = () => {
localStorage.removeItem("sessionToken");
currentUser.current = null;
onLoginSuccess(null);
};
const deleteUser = async () => {
if (!currentUser.current) return onFailure("No user logged in");
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("deleteUser", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
}, resolve);
});
if (response?.success) {
logoutUser();
onDeleteSuccess(response);
} else {
throw new Error(response?.error || "User deletion failed");
}
} catch (error) {
onFailure(error.message);
}
};
const saveHost = async (hostConfig) => {
if (!currentUser.current) return onFailure("Not authenticated");
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("saveHostConfig", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
...hostConfig
}, resolve);
});
if (!response?.success) {
throw new Error(response?.error || "Failed to save host");
}
} catch (error) {
onFailure(error.message);
}
};
const getAllHosts = async () => {
if (!currentUser.current) return [];
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("getHosts", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
}, resolve);
});
if (response?.success) {
return response.hosts.map(host => ({
...host,
config: host.config ? {
name: host.config.name || '',
folder: host.config.folder || '',
ip: host.config.ip || '',
user: host.config.user || '',
port: host.config.port || '22',
password: host.config.password || '',
rsaKey: host.config.rsaKey || '',
} : {}
})).filter(host => host.config && host.config.ip && host.config.user);
} else {
throw new Error(response?.error || "Failed to fetch hosts");
}
} catch (error) {
onFailure(error.message);
return [];
}
};
const deleteHost = async ({ hostId }) => {
if (!currentUser.current) return onFailure("Not authenticated");
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("deleteHost", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
hostId: hostId,
}, resolve);
});
if (!response?.success) {
throw new Error(response?.error || "Failed to delete host");
}
} catch (error) {
onFailure(error.message);
}
};
const editHost = async ({ oldHostConfig, newHostConfig }) => {
if (!currentUser.current) return onFailure("Not authenticated");
try {
console.log('Editing host with configs:', { oldHostConfig, newHostConfig });
const response = await new Promise((resolve) => {
socketRef.current.emit("editHost", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
oldHostConfig,
newHostConfig,
}, resolve);
});
if (!response?.success) {
throw new Error(response?.error || "Failed to edit host");
}
} catch (error) {
onFailure(error.message);
}
};
const shareHost = async (hostId, targetUsername) => {
if (!currentUser.current) return onFailure("Not authenticated");
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("shareHost", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
hostId,
targetUsername,
}, resolve);
});
if (!response?.success) {
throw new Error(response?.error || "Failed to share host");
}
} catch (error) {
onFailure(error.message);
}
};
const removeShare = async (hostId) => {
if (!currentUser.current) return onFailure("Not authenticated");
try {
const response = await new Promise((resolve) => {
socketRef.current.emit("removeShare", {
userId: currentUser.current.id,
sessionToken: currentUser.current.sessionToken,
hostId,
}, resolve);
});
if (!response?.success) {
throw new Error(response?.error || "Failed to remove share");
}
} catch (error) {
onFailure(error.message);
}
};
useImperativeHandle(ref, () => ({
createUser,
loginUser,
loginAsGuest,
logoutUser,
deleteUser,
saveHost,
getAllHosts,
deleteHost,
shareHost,
editHost,
removeShare,
getUser: () => currentUser.current,
}));
return null;
});
User.displayName = "User";
User.propTypes = {
onLoginSuccess: PropTypes.func.isRequired,
onCreateSuccess: PropTypes.func.isRequired,
onDeleteSuccess: PropTypes.func.isRequired,
onFailure: PropTypes.func.isRequired,
};
+460
View File
@@ -0,0 +1,460 @@
const http = require('http');
const socketIo = require('socket.io');
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
require('dotenv').config();
const logger = {
info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args),
error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args),
warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args),
debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args)
};
const server = http.createServer();
const io = socketIo(server, {
path: '/database.io/socket.io',
cors: { origin: '*', methods: ['GET', 'POST'] }
});
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
password: { type: String, required: true },
sessionToken: { type: String, required: true }
});
const hostSchema = new mongoose.Schema({
name: { type: String, required: true },
config: { type: String, required: true },
users: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
folder: { type: String, default: null }
});
const User = mongoose.model('User', userSchema);
const Host = mongoose.model('Host', hostSchema);
const getEncryptionKey = (userId, sessionToken) => {
const salt = process.env.SALT || 'default_salt';
return crypto.scryptSync(`${userId}-${sessionToken}`, salt, 32);
};
const encryptData = (data, userId, sessionToken) => {
try {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv);
const encrypted = Buffer.concat([cipher.update(JSON.stringify(data)), cipher.final()]);
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`;
} catch (error) {
logger.error('Encryption failed:', error);
return null;
}
};
const decryptData = (encryptedData, userId, sessionToken) => {
try {
const [ivHex, contentHex, authTagHex] = encryptedData.split(':');
const iv = Buffer.from(ivHex, 'hex');
const content = Buffer.from(contentHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', getEncryptionKey(userId, sessionToken), iv);
decipher.setAuthTag(authTag);
return JSON.parse(Buffer.concat([decipher.update(content), decipher.final()]).toString());
} catch (error) {
logger.error('Decryption failed:', error);
return null;
}
};
mongoose.connect(process.env.MONGO_URL || 'mongodb://localhost:27017/termix')
.then(() => logger.info('Connected to MongoDB'))
.catch(err => logger.error('MongoDB connection error:', err));
io.of('/database.io').on('connection', (socket) => {
socket.on('createUser', async ({ username, password }, callback) => {
try {
logger.debug(`Creating user: ${username}`);
if (await User.exists({ username })) {
logger.warn(`Username already exists: ${username}`);
return callback({ error: 'Username already exists' });
}
const sessionToken = crypto.randomBytes(64).toString('hex');
const user = await User.create({
username,
password: await bcrypt.hash(password, 10),
sessionToken
});
logger.info(`User created: ${username}`);
callback({ success: true, user: {
id: user._id,
username: user.username,
sessionToken
}});
} catch (error) {
logger.error('User creation error:', error);
callback({ error: 'User creation failed' });
}
});
socket.on('loginUser', async ({ username, password, sessionToken }, callback) => {
try {
let user;
if (sessionToken) {
user = await User.findOne({ sessionToken });
} else {
user = await User.findOne({ username });
if (!user || !(await bcrypt.compare(password, user.password))) {
logger.warn(`Invalid credentials for: ${username}`);
return callback({ error: 'Invalid credentials' });
}
}
if (!user) {
logger.warn('Login failed - user not found');
return callback({ error: 'Invalid credentials' });
}
logger.info(`User logged in: ${user.username}`);
callback({ success: true, user: {
id: user._id,
username: user.username,
sessionToken: user.sessionToken
}});
} catch (error) {
logger.error('Login error:', error);
callback({ error: 'Login failed' });
}
});
socket.on('loginAsGuest', async (callback) => {
try {
const username = `guest-${crypto.randomBytes(4).toString('hex')}`;
const sessionToken = crypto.randomBytes(64).toString('hex');
const user = await User.create({
username,
password: await bcrypt.hash(username, 10),
sessionToken
});
logger.info(`Guest user created: ${username}`);
callback({ success: true, user: {
id: user._id,
username: user.username,
sessionToken
}});
} catch (error) {
logger.error('Guest login error:', error);
callback({error: 'Guest login failed'});
}
});
socket.on('saveHostConfig', async ({ userId, sessionToken, hostConfig }, callback) => {
try {
if (!userId || !sessionToken) {
logger.warn('Missing authentication parameters');
return callback({ error: 'Authentication required' });
}
if (!hostConfig || typeof hostConfig !== 'object') {
logger.warn('Invalid host config format');
return callback({ error: 'Invalid host configuration' });
}
if (!hostConfig.ip || !hostConfig.user) {
logger.warn('Missing required fields:', hostConfig);
return callback({ error: 'IP and User are required' });
}
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const cleanConfig = {
name: hostConfig.name?.trim(),
folder: hostConfig.folder?.trim() || null,
ip: hostConfig.ip.trim(),
user: hostConfig.user.trim(),
port: hostConfig.port || 22,
password: hostConfig.password?.trim() || undefined,
rsaKey: hostConfig.rsaKey?.trim() || undefined
};
const finalName = cleanConfig.name || cleanConfig.ip;
const existingHost = await Host.findOne({
name: finalName,
createdBy: userId
});
if (existingHost) {
logger.warn(`Host with name ${finalName} already exists for user: ${userId}`);
return callback({ error: 'Host with this name already exists' });
}
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
if (!encryptedConfig) {
logger.error('Encryption failed for host config');
return callback({ error: 'Configuration encryption failed' });
}
await Host.create({
name: finalName,
config: encryptedConfig,
users: [userId],
createdBy: userId,
folder: cleanConfig.folder
});
logger.info(`Host created successfully: ${finalName}`);
callback({ success: true });
} catch (error) {
logger.error('Host save error:', error);
callback({ error: `Host save failed: ${error.message}` });
}
});
socket.on('getHosts', async ({ userId, sessionToken }, callback) => {
try {
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const hosts = await Host.find({ users: userId }).populate('createdBy');
const decryptedHosts = await Promise.all(hosts.map(async host => {
try {
const ownerUser = host.createdBy;
if (!ownerUser) {
logger.warn(`Owner not found for host: ${host._id}`);
return null;
}
const decryptedConfig = decryptData(host.config, ownerUser._id.toString(), ownerUser.sessionToken);
if (!decryptedConfig) {
logger.warn(`Failed to decrypt host config for host: ${host._id}`);
return null;
}
return {
...host.toObject(),
config: decryptedConfig
};
} catch (error) {
logger.error(`Failed to process host ${host._id}:`, error);
return null;
}
}));
callback({ success: true, hosts: decryptedHosts.filter(host => host && host.config) });
} catch (error) {
logger.error('Get hosts error:', error);
callback({ error: 'Failed to fetch hosts' });
}
});
socket.on('deleteHost', async ({ userId, sessionToken, hostId }, callback) => {
try {
logger.debug(`Deleting host: ${hostId} for user: ${userId}`);
if (!userId || !sessionToken) {
logger.warn('Missing authentication parameters');
return callback({ error: 'Authentication required' });
}
if (!hostId || typeof hostId !== 'string') {
logger.warn('Invalid host ID format');
return callback({ error: 'Invalid host ID' });
}
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const result = await Host.deleteOne({ _id: hostId, createdBy: userId });
if (result.deletedCount === 0) {
logger.warn(`Host not found or not authorized: ${hostId}`);
return callback({ error: 'Host not found or not authorized' });
}
logger.info(`Host deleted: ${hostId}`);
callback({ success: true });
} catch (error) {
logger.error('Host deletion error:', error);
callback({ error: `Host deletion failed: ${error.message}` });
}
});
socket.on('shareHost', async ({ userId, sessionToken, hostId, targetUsername }, callback) => {
try {
logger.debug(`Sharing host ${hostId} with ${targetUsername}`);
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const targetUser = await User.findOne({ username: targetUsername });
if (!targetUser) {
logger.warn(`Target user not found: ${targetUsername}`);
return callback({ error: 'User not found' });
}
const host = await Host.findOne({ _id: hostId, createdBy: userId });
if (!host) {
logger.warn(`Host not found or unauthorized: ${hostId}`);
return callback({ error: 'Host not found' });
}
if (host.users.includes(targetUser._id)) {
logger.warn(`Host already shared with user: ${targetUsername}`);
return callback({ error: 'Already shared' });
}
host.users.push(targetUser._id);
await host.save();
logger.info(`Host shared successfully: ${hostId} -> ${targetUsername}`);
callback({ success: true });
} catch (error) {
logger.error('Host sharing error:', error);
callback({ error: 'Failed to share host' });
}
});
socket.on('removeShare', async ({ userId, sessionToken, hostId }, callback) => {
try {
logger.debug(`Removing share for host ${hostId} from user ${userId}`);
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const host = await Host.findById(hostId);
if (!host) {
logger.warn(`Host not found: ${hostId}`);
return callback({ error: 'Host not found' });
}
host.users = host.users.filter(id => id.toString() !== userId);
await host.save();
logger.info(`Share removed successfully: ${hostId} -> ${userId}`);
callback({ success: true });
} catch (error) {
logger.error('Share removal error:', error);
callback({ error: 'Failed to remove share' });
}
});
socket.on('deleteUser', async ({ userId, sessionToken }, callback) => {
try {
logger.debug(`Deleting user: ${userId}`);
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
await Host.deleteMany({ createdBy: userId });
await User.deleteOne({ _id: userId });
logger.info(`User deleted: ${userId}`);
callback({ success: true });
} catch (error) {
logger.error('User deletion error:', error);
callback({ error: 'Failed to delete user' });
}
});
socket.on("editHost", async ({ userId, sessionToken, oldHostConfig, newHostConfig }, callback) => {
try {
logger.debug(`Editing host for user: ${userId}`);
if (!oldHostConfig || !newHostConfig) {
logger.warn('Missing host configurations');
return callback({ error: 'Missing host configurations' });
}
const user = await User.findOne({ _id: userId, sessionToken });
if (!user) {
logger.warn(`Invalid session for user: ${userId}`);
return callback({ error: 'Invalid session' });
}
const hosts = await Host.find({ createdBy: userId });
const host = hosts.find(h => {
const decryptedConfig = decryptData(h.config, userId, sessionToken);
return decryptedConfig && decryptedConfig.ip === oldHostConfig.ip;
});
if (!host) {
logger.warn(`Host not found or unauthorized`);
return callback({ error: 'Host not found' });
}
const cleanConfig = {
name: newHostConfig.name?.trim(),
folder: newHostConfig.folder?.trim() || null,
ip: newHostConfig.ip.trim(),
user: newHostConfig.user.trim(),
port: newHostConfig.port || 22,
password: newHostConfig.password?.trim() || undefined,
rsaKey: newHostConfig.rsaKey?.trim() || undefined
};
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
if (!encryptedConfig) {
logger.error('Encryption failed for host config');
return callback({ error: 'Configuration encryption failed' });
}
host.config = encryptedConfig;
host.folder = cleanConfig.folder;
await host.save();
logger.info(`Host edited successfully`);
callback({ success: true });
} catch (error) {
logger.error('Host edit error:', error);
callback({ error: 'Failed to edit host' });
}
});
socket.on('verifySession', async ({ sessionToken }, callback) => {
try {
const user = await User.findOne({ sessionToken });
if (!user) {
logger.warn(`Invalid session token: ${sessionToken}`);
return callback({ error: 'Invalid session' });
}
callback({ success: true, user: {
id: user._id,
username: user.username
}});
} catch (error) {
logger.error('Session verification error:', error);
callback({ error: 'Session verification failed' });
}
});
});
server.listen(8082, () => {
logger.info('Server running on port 8082');
});
File diff suppressed because it is too large Load Diff
-544
View File
@@ -1,544 +0,0 @@
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)) {
databaseLogger.info(`Creating database directory`, {
operation: "db_init",
path: 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`;
let actualDbPath = ":memory:";
let memoryDatabase: Database.Database;
let isNewDatabase = false;
let sqlite: Database.Database;
async function initializeDatabaseAsync(): Promise<void> {
const systemCrypto = SystemCrypto.getInstance();
const dbKey = await systemCrypto.getDatabaseKey();
if (enableFileEncryption) {
try {
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
const decryptedBuffer =
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
memoryDatabase = new Database(decryptedBuffer);
} 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,
});
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<void> {
await initializeDatabaseAsync();
databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: actualDbPath,
encrypted:
enableFileEncryption &&
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
inMemory: true,
isNewDatabase,
});
sqlite = memoryDatabase;
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 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,
default_path TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
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),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
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),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
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),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
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)
);
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 NOT NULL,
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)
);
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),
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
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,
});
}
}
const addColumnIfNotExists = (
table: string,
column: string,
definition: string,
) => {
try {
sqlite
.prepare(
`SELECT ${column}
FROM ${table} LIMIT 1`,
)
.get();
} catch (e) {
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",
"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",
"credential_id",
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
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");
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 });
}
if (enableFileEncryption) {
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
buffer,
encryptedDbPath,
);
} else {
fs.writeFileSync(dbPath, buffer);
}
} 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(saveMemoryDatabaseToFile, 15 * 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<void> {
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 {}
}
try {
fs.rmdirSync(tempDir);
} catch {}
}
} catch (error) {}
}
process.on("exit", () => {
if (sqlite) {
try {
sqlite.close();
} catch {}
}
});
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<typeof drizzle<typeof schema>>;
export function getDb(): ReturnType<typeof drizzle<typeof schema>> {
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 };
-174
View File
@@ -1,174 +0,0 @@
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(),
password_hash: text("password_hash").notNull(),
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidc_identifier: text("oidc_identifier"),
client_id: text("client_id"),
client_secret: text("client_secret"),
issuer_url: text("issuer_url"),
authorization_url: text("authorization_url"),
token_url: text("token_url"),
identifier_path: text("identifier_path"),
name_path: text("name_path"),
scopes: text().default("openid email profile"),
totp_secret: text("totp_secret"),
totp_enabled: integer("totp_enabled", { mode: "boolean" })
.notNull()
.default(false),
totp_backup_codes: text("totp_backup_codes"),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
export const sshData = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
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(),
password: text("password"),
key: text("key", { length: 8192 }),
key_password: text("key_password"),
keyType: text("key_type"),
autostartPassword: text("autostart_password"),
autostartKey: text("autostart_key", { length: 8192 }),
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
.notNull()
.default(true),
tunnelConnections: text("tunnel_connections"),
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
defaultPath: text("default_path"),
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),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
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),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
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),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
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),
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),
name: text("name").notNull(),
description: text("description"),
folder: text("folder"),
tags: text("tags"),
authType: text("auth_type").notNull(),
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
private_key: text("private_key", { length: 16384 }),
public_key: text("public_key", { length: 4096 }),
key_password: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
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),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
usedAt: text("used_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
-239
View File
@@ -1,239 +0,0 @@
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";
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
}
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get(key: string): any | 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;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = "LukeGus";
const REPO_NAME = "Termix-Docs";
const ALERTS_FILE = "main/termix-alerts.json";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: "low" | "medium" | "high" | "critical";
type?: "info" | "warning" | "error" | "success";
actionUrl?: string;
actionText?: string;
}
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
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",
},
});
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();
// Route: Get alerts for the authenticated user (excluding dismissed ones)
// GET /alerts
router.get("/", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).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" });
}
});
// Route: Dismiss an alert for the authenticated user
// POST /alerts/dismiss
router.post("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as any).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" });
}
const result = 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" });
}
});
// Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId
router.get("/dismissed", authenticateJWT, async (req, res) => {
try {
const userId = (req as any).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" });
}
});
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
// DELETE /alerts/dismiss
router.delete("/dismiss", authenticateJWT, async (req, res) => {
try {
const { alertId } = req.body;
const userId = (req as any).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 diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
const http = require("http");
const socketIo = require("socket.io");
const SSHClient = require("ssh2").Client;
const server = http.createServer();
const io = socketIo(server, {
path: "/ssh.io/socket.io",
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true
},
allowEIO3: true
});
const logger = {
info: (...args) => console.log(`🔧 [${new Date().toISOString()}] INFO:`, ...args),
error: (...args) => console.error(`❌ [${new Date().toISOString()}] ERROR:`, ...args),
warn: (...args) => console.warn(`⚠️ [${new Date().toISOString()}] WARN:`, ...args),
debug: (...args) => console.debug(`🔍 [${new Date().toISOString()}] DEBUG:`, ...args)
};
io.on("connection", (socket) => {
logger.info("New socket connection established");
let stream = null;
socket.on("connectToHost", (cols, rows, hostConfig) => {
if (!hostConfig || !hostConfig.ip || !hostConfig.user || !hostConfig.port) {
logger.error("Invalid hostConfig received - missing required fields:", hostConfig);
socket.emit("error", "Missing required connection details (IP, user, or port)");
return;
}
if (!hostConfig.password && !hostConfig.rsaKey) {
logger.error("No authentication provided");
socket.emit("error", "Authentication required");
return;
}
const safeHostConfig = {
ip: hostConfig.ip,
port: hostConfig.port,
user: hostConfig.user,
authType: hostConfig.password ? 'password' : 'public key',
};
logger.info("Connecting with config:", safeHostConfig);
const { ip, port, user, password, rsaKey } = hostConfig;
const conn = new SSHClient();
conn
.on("ready", function () {
logger.info("SSH connection established");
conn.shell({ term: "xterm-256color" }, function (err, newStream) {
if (err) {
logger.error("Shell error:", err.message);
socket.emit("error", err.message);
return;
}
stream = newStream;
stream.setWindow(rows, cols, rows * 100, cols * 100);
stream.on("data", function (data) {
socket.emit("data", data);
});
stream.on("close", function () {
logger.info("SSH stream closed");
conn.end();
});
socket.on("data", function (data) {
stream.write(data);
});
socket.on("resize", ({ cols, rows }) => {
if (stream && stream.setWindow) {
stream.setWindow(rows, cols, rows * 100, cols * 100);
}
});
socket.emit("resize", { cols, rows });
});
})
.on("close", function () {
logger.info("SSH connection closed");
socket.emit("error", "SSH connection closed");
})
.on("error", function (err) {
logger.error("Error:", err.message);
socket.emit("error", err.message);
})
.connect({
host: ip,
port: port,
username: user,
password: password,
privateKey: rsaKey ? Buffer.from(rsaKey) : undefined,
});
});
socket.on("disconnect", () => {
logger.info("Client disconnected");
});
});
server.listen(8081, '0.0.0.0', () => {
logger.info("Server is running on port 8081");
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-733
View File
@@ -1,733 +0,0 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { parse as parseUrl } from "url";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } 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";
const authManager = AuthManager.getInstance();
const userCrypto = UserCrypto.getInstance();
const userConnections = new Map<string, Set<WebSocket>>();
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) {
sshLogger.warn("WebSocket connection rejected: missing token", {
operation: "websocket_auth_reject",
reason: "missing_token",
ip: info.req.socket.remoteAddress,
});
return false;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload) {
sshLogger.warn("WebSocket connection rejected: invalid token", {
operation: "websocket_auth_reject",
reason: "invalid_token",
ip: info.req.socket.remoteAddress,
});
return false;
}
if (payload.pendingTOTP) {
sshLogger.warn(
"WebSocket connection rejected: TOTP verification pending",
{
operation: "websocket_auth_reject",
reason: "totp_pending",
userId: payload.userId,
ip: info.req.socket.remoteAddress,
},
);
return false;
}
const existingConnections = userConnections.get(payload.userId);
if (existingConnections && existingConnections.size >= 3) {
sshLogger.warn("WebSocket connection rejected: too many connections", {
operation: "websocket_auth_reject",
reason: "connection_limit",
userId: payload.userId,
currentConnections: existingConnections.size,
ip: info.req.socket.remoteAddress,
});
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 userPayload: any;
try {
const url = parseUrl(req.url!, true);
const token = url.query.token as string;
if (!token) {
sshLogger.warn(
"WebSocket connection rejected: missing token in connection",
{
operation: "websocket_connection_reject",
reason: "missing_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload) {
sshLogger.warn(
"WebSocket connection rejected: invalid token in connection",
{
operation: "websocket_connection_reject",
reason: "invalid_token",
ip: req.socket.remoteAddress,
},
);
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
userPayload = payload;
} 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) {
sshLogger.warn("WebSocket connection rejected: data locked", {
operation: "websocket_data_locked",
userId,
ip: req.socket.remoteAddress,
});
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);
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on("close", () => {
const userWs = userConnections.get(userId);
if (userWs) {
userWs.delete(ws);
if (userWs.size === 0) {
userConnections.delete(userId);
}
}
cleanupSSH();
});
ws.on("message", (msg: RawData) => {
const currentDataKey = userCrypto.getUserDataKey(userId);
if (!currentDataKey) {
sshLogger.warn("WebSocket message rejected: data access expired", {
operation: "websocket_message_rejected",
userId,
reason: "data_access_expired",
});
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: any;
try {
parsed = JSON.parse(msg.toString());
} 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":
if (data.hostConfig) {
data.hostConfig.userId = userId;
}
handleConnectToHost(data).catch((error) => {
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
userId,
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
case "resize":
handleResize(data);
break;
case "disconnect":
cleanupSSH();
break;
case "input":
if (sshStream) {
if (data === "\t") {
sshStream.write(data);
} else if (data.startsWith("\x1b")) {
sshStream.write(data);
} else {
try {
sshStream.write(Buffer.from(data, "utf8"));
} catch (error) {
sshLogger.error("Error writing input to SSH stream", error, {
operation: "ssh_input_encoding",
userId,
dataLength: data.length,
});
sshStream.write(Buffer.from(data, "latin1"));
}
}
}
break;
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
userId,
messageType: type,
});
}
});
async function handleConnectToHost(data: {
cols: number;
rows: number;
hostConfig: {
id: number;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
credentialId?: number;
userId?: string;
};
initialPath?: string;
executeCommand?: string;
}) {
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
const {
id,
ip,
port,
username,
password,
key,
keyPassword,
keyType,
authType,
credentialId,
} = hostConfig;
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;
}
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
cleanupSSH(connectionTimeout);
}
}, 60000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await SimpleDBOps.select(
getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
),
"ssh_credentials",
hostConfig.userId,
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key:
credential.private_key || credential.privateKey || credential.key,
keyPassword: credential.key_password || credential.keyPassword,
keyType: credential.key_type || credential.keyType,
authType: credential.auth_type || credential.authType,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
userId: hostConfig.userId,
});
}
} 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 if (credentialId && id) {
sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
hasUserId: !!hostConfig.userId,
});
}
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
sshConn!.shell(
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
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,
}),
);
return;
}
sshStream = stream;
stream.on("data", (data: Buffer) => {
try {
const utf8String = data.toString("utf-8");
ws.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,
});
ws.send(
JSON.stringify({
type: "data",
data: data.toString("latin1"),
}),
);
}
});
stream.on("close", () => {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
});
setupPingInterval();
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" }),
);
},
);
});
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
sshLogger.error("SSH connection error", err, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
});
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("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 }));
cleanupSSH(connectionTimeout);
});
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
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: [
"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 (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
connectConfig.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
) {
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;
}
if (
resolvedCredentials.keyType &&
resolvedCredentials.keyType !== "auto"
) {
connectConfig.privateKeyType = resolvedCredentials.keyType;
}
} 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") {
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 {
sshLogger.error("No valid authentication method provided");
ws.send(
JSON.stringify({
type: "error",
message: "No valid authentication method provided",
}),
);
return;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
sshLogger.error("Error closing stream: " + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
sshLogger.error("Error closing connection: " + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: any) {
sshLogger.error("SSH keepalive failed: " + e.message);
cleanupSSH();
}
}
}, 60000);
}
});
File diff suppressed because it is too large Load Diff
-141
View File
@@ -1,141 +0,0 @@
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 () => {
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 {}
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 (error) {
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();
const dbModule = await import("./database/db/index.js");
await dbModule.initializeDatabase();
const authManager = AuthManager.getInstance();
await authManager.initialize();
DataCrypto.initialize();
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");
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("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, {
operation: "error_handling",
});
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
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);
}
})();
-310
View File
@@ -1,310 +0,0 @@
import jwt from "jsonwebtoken";
import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
interface AuthenticationResult {
success: boolean;
token?: string;
userId?: string;
isAdmin?: boolean;
username?: string;
requiresTOTP?: boolean;
tempToken?: string;
error?: string;
}
interface JWTPayload {
userId: string;
pendingTOTP?: boolean;
iat?: number;
exp?: number;
}
class AuthManager {
private static instance: AuthManager;
private systemCrypto: SystemCrypto;
private userCrypto: UserCrypto;
private invalidatedTokens: Set<string> = new Set();
private constructor() {
this.systemCrypto = SystemCrypto.getInstance();
this.userCrypto = UserCrypto.getInstance();
this.userCrypto.setSessionExpiredCallback((userId: string) => {
this.invalidateUserTokens(userId);
});
}
static getInstance(): AuthManager {
if (!this.instance) {
this.instance = new AuthManager();
}
return this.instance;
}
async initialize(): Promise<void> {
await this.systemCrypto.initializeJWTSecret();
}
async registerUser(userId: string, password: string): Promise<void> {
await this.userCrypto.setupUserEncryption(userId, password);
}
async registerOIDCUser(userId: string): Promise<void> {
await this.userCrypto.setupOIDCUserEncryption(userId);
}
async authenticateOIDCUser(userId: string): Promise<boolean> {
const authenticated = await this.userCrypto.authenticateOIDCUser(userId);
if (authenticated) {
await this.performLazyEncryptionMigration(userId);
}
return authenticated;
}
async authenticateUser(userId: string, password: string): Promise<boolean> {
const authenticated = await this.userCrypto.authenticateUser(
userId,
password,
);
if (authenticated) {
await this.performLazyEncryptionMigration(userId);
}
return authenticated;
}
private async performLazyEncryptionMigration(userId: string): Promise<void> {
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();
} else {
}
} 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 } = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload: JWTPayload = { userId };
if (options.pendingTOTP) {
payload.pendingTOTP = true;
}
return jwt.sign(payload, jwtSecret, {
expiresIn: options.expiresIn || "24h",
} as jwt.SignOptions);
}
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
try {
if (this.invalidatedTokens.has(token)) {
return null;
}
const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
return payload;
} catch (error) {
databaseLogger.warn("JWT verification failed", {
operation: "jwt_verify_failed",
error: error instanceof Error ? error.message : "Unknown error",
});
return null;
}
}
invalidateJWTToken(token: string): void {
this.invalidatedTokens.add(token);
}
invalidateUserTokens(userId: string): void {
databaseLogger.info("User tokens invalidated due to data lock", {
operation: "user_tokens_invalidate",
userId,
});
}
getSecureCookieOptions(req: any, 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: "/",
};
}
createAuthMiddleware() {
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" });
}
(req as any).userId = payload.userId;
(req as any).pendingTOTP = payload.pendingTOTP;
next();
};
}
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = (req as any).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
const dataKey = this.userCrypto.getUserDataKey(userId);
if (!dataKey) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
(req as any).dataKey = dataKey;
next();
};
}
createAdminMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers["authorization"];
if (!authHeader?.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing Authorization header" });
}
const token = authHeader.split(" ")[1];
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].is_admin) {
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" });
}
(req as any).userId = payload.userId;
(req as any).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" });
}
};
}
logoutUser(userId: string): void {
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<boolean> {
return await this.userCrypto.changeUserPassword(
userId,
oldPassword,
newPassword,
);
}
async resetUserPasswordWithPreservedDEK(
userId: string,
newPassword: string,
): Promise<boolean> {
return await this.userCrypto.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
}
}
export { AuthManager, type AuthenticationResult, type JWTPayload };
-280
View File
@@ -1,280 +0,0 @@
import { execSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
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<void> {
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<boolean> {
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<void> {
try {
try {
execSync("openssl version", { stdio: "pipe" });
} catch (error) {
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<void> {
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<void> {
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 {}
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",
};
}
}
-444
View File
@@ -1,444 +0,0 @@
import { FieldCrypto } from "./field-crypto.js";
import { LazyFieldEncryption } from "./lazy-field-encryption.js";
import { UserCrypto } from "./user-crypto.js";
import { databaseLogger } from "./logger.js";
class DataCrypto {
private static userCrypto: UserCrypto;
static initialize() {
this.userCrypto = UserCrypto.getInstance();
}
static encryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
const encryptedRecord = { ...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,
fieldName,
);
}
}
return encryptedRecord;
}
static decryptRecord(
tableName: string,
record: any,
userId: string,
userDataKey: Buffer,
): any {
if (!record) return record;
const decryptedRecord = { ...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,
fieldName,
);
}
}
return decryptedRecord;
}
static decryptRecords(
tableName: string,
records: any[],
userId: string,
userDataKey: Buffer,
): any[] {
if (!Array.isArray(records)) return records;
return records.map((record) =>
this.decryptRecord(tableName, record, userId, userDataKey),
);
}
static async migrateUserSensitiveFields(
userId: string,
userDataKey: Buffer,
db: any,
): Promise<{
migrated: boolean;
migratedTables: string[];
migratedFieldsCount: number;
}> {
let migrated = false;
const migratedTables: string[] = [];
let migratedFieldsCount = 0;
try {
const { needsMigration, plaintextFields } =
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);
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 = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
updatedRecord.key_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);
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 = ?, 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,
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);
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 = ?
WHERE id = ?
`;
db.prepare(updateQuery).run(
updatedRecord.totp_secret || null,
updatedRecord.totp_backup_codes || 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: any,
): 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"] },
{
table: "ssh_credentials",
fields: [
"password",
"private_key",
"key_password",
"key",
"public_key",
],
},
{
table: "users",
fields: [
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
],
},
];
for (const { table, fields } of tablesToReencrypt) {
try {
const records = db
.prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
.all(userId);
for (const record of records) {
const recordId = record.id.toString();
let needsUpdate = false;
const updatedRecord = { ...record };
for (const fieldName of fields) {
const fieldValue = record[fieldName];
if (fieldValue && 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 updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, 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;
databaseLogger.info(
"User data re-encryption completed after password reset",
{
operation: "password_reset_reencrypt_completed",
userId,
success: result.success,
reencryptedTables: result.reencryptedTables,
reencryptedFieldsCount: result.reencryptedFieldsCount,
errorsCount: result.errors.length,
},
);
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: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.encryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordForUser(
tableName: string,
record: any,
userId: string,
): any {
const userDataKey = this.validateUserAccess(userId);
return this.decryptRecord(tableName, record, userId, userDataKey);
}
static decryptRecordsForUser(
tableName: string,
records: any[],
userId: string,
): any[] {
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 (error) {
return false;
}
}
}
export { DataCrypto };
@@ -1,400 +0,0 @@
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;
}
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<string> {
try {
const key = await this.systemCrypto.getDatabaseKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
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",
};
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
fs.writeFileSync(targetPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
return targetPath;
} catch (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<string> {
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}`;
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 any;
const encrypted = Buffer.concat([
cipher.update(sourceData),
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",
};
fs.writeFileSync(encryptedPath, encrypted);
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
databaseLogger.info("Database file encrypted successfully", {
operation: "database_file_encryption",
sourcePath,
encryptedPath,
fileSize: sourceData.length,
encryptedSize: encrypted.length,
fingerprintPrefix: metadata.fingerprint,
});
return encryptedPath;
} catch (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<Buffer> {
if (!fs.existsSync(encryptedPath)) {
throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
}
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(`Metadata file does not exist: ${metadataPath}`);
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const encryptedData = fs.readFileSync(encryptedPath);
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 any;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decryptedBuffer = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
return decryptedBuffer;
} catch (error) {
databaseLogger.error("Failed to decrypt database to buffer", error, {
operation: "database_buffer_decryption_failed",
encryptedPath,
});
throw new Error(
`Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
static async decryptDatabaseFile(
encryptedPath: string,
targetPath?: string,
): Promise<string> {
if (!fs.existsSync(encryptedPath)) {
throw new Error(
`Encrypted database file does not exist: ${encryptedPath}`,
);
}
const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(metadataPath)) {
throw new Error(`Metadata file does not exist: ${metadataPath}`);
}
const decryptedPath =
targetPath || encryptedPath.replace(this.ENCRYPTED_FILE_SUFFIX, "");
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const encryptedData = fs.readFileSync(encryptedPath);
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 any;
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final(),
]);
fs.writeFileSync(decryptedPath, decrypted);
databaseLogger.info("Database file decrypted successfully", {
operation: "database_file_decryption",
encryptedPath,
decryptedPath,
encryptedSize: encryptedData.length,
decryptedSize: decrypted.length,
fingerprintPrefix: metadata.fingerprint,
});
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 {
const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`;
if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) {
return false;
}
try {
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
return (
metadata.version === this.VERSION &&
metadata.algorithm === this.ALGORITHM
);
} 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 metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`;
const metadataContent = fs.readFileSync(metadataPath, "utf8");
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
const fileStats = fs.statSync(encryptedPath);
const currentFingerprint = "termix-v1-file";
return {
version: metadata.version,
algorithm: metadata.algorithm,
fingerprint: metadata.fingerprint,
isCurrentHardware: true,
fileSize: fileStats.size,
};
} catch {
return null;
}
}
static async createEncryptedBackup(
databasePath: string,
backupDir: string,
): Promise<string> {
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<string> {
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 };
-404
View File
@@ -1,404 +0,0 @@
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;
const encryptedSize = fs.statSync(this.encryptedDbPath).size;
if (unencryptedSize === 0) {
needsMigration = false;
reason =
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
try {
fs.unlinkSync(this.unencryptedDbPath);
databaseLogger.info("Removed empty unencrypted database file", {
operation: "migration_cleanup_empty",
path: 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<boolean> {
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;
}
let totalOriginalRows = 0;
let totalMemoryRows = 0;
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 };
totalOriginalRows += originalCount.count;
totalMemoryRows += memoryCount.count;
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<MigrationResult> {
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();
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: any[]) => {
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",
});
}
}
}
-118
View File
@@ -1,118 +0,0 @@
import { databaseLogger } from "./logger.js";
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static saveTimeout: NodeJS.Timeout | null = null;
static initialize(saveFunction: () => Promise<void>): void {
this.saveFunction = saveFunction;
this.isInitialized = true;
}
static async triggerSave(
reason: string = "data_modification",
): Promise<void> {
if (!this.isInitialized || !this.saveFunction) {
databaseLogger.warn("Database save trigger not initialized", {
operation: "db_save_trigger_not_init",
reason,
});
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(async () => {
if (this.pendingSave) {
return;
}
this.pendingSave = true;
try {
await this.saveFunction!();
} 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<void> {
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 {
databaseLogger.info("Force saving database", {
operation: "db_save_trigger_force_start",
reason,
});
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;
databaseLogger.info("Database save trigger cleaned up", {
operation: "db_save_trigger_cleanup",
});
}
}
-108
View File
@@ -1,108 +0,0 @@
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([
"password_hash",
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
]),
ssh_data: new Set(["password", "key", "key_password"]),
ssh_credentials: new Set([
"password",
"private_key",
"key_password",
"key",
"public_key",
]),
};
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 any;
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 any;
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 };
-393
View File
@@ -1,393 +0,0 @@
import { FieldCrypto } from "./field-crypto.js";
import { databaseLogger } from "./logger.js";
export class LazyFieldEncryption {
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
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 (jsonError) {
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 (legacyError) {}
}
const sensitiveFields = [
"totp_secret",
"totp_backup_codes",
"password",
"key",
"key_password",
"private_key",
"public_key",
"client_secret",
"oidc_identifier",
];
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 (error) {
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 (legacyError) {}
}
return {
encrypted: fieldValue,
wasPlaintext: false,
wasLegacyEncryption: false,
};
}
}
}
static migrateRecordSensitiveFields(
record: any,
sensitiveFields: string[],
userKEK: Buffer,
recordId: string,
): {
updatedRecord: any;
migratedFields: string[];
needsUpdate: boolean;
} {
const updatedRecord = { ...record };
const migratedFields: string[] = [];
let needsUpdate = false;
for (const fieldName of sensitiveFields) {
const fieldValue = record[fieldName];
if (fieldValue) {
try {
const { encrypted, wasPlaintext, wasLegacyEncryption } =
this.migrateFieldToEncrypted(
fieldValue,
userKEK,
recordId,
fieldName,
);
if (wasPlaintext || wasLegacyEncryption) {
updatedRecord[fieldName] = 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 };
}
static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record<string, string[]> = {
ssh_data: ["password", "key", "key_password"],
ssh_credentials: [
"password",
"key",
"key_password",
"private_key",
"public_key",
],
users: ["totp_secret", "totp_backup_codes"],
};
return sensitiveFieldsMap[tableName] || [];
}
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 (error) {
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
if (legacyFieldName) {
try {
FieldCrypto.decryptField(
fieldValue,
userKEK,
recordId,
legacyFieldName,
);
return true;
} catch (legacyError) {
return false;
}
}
return false;
}
}
static async checkUserNeedsMigration(
userId: string,
userKEK: Buffer,
db: any,
): 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);
for (const host of sshHosts) {
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
const hostPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
if (
host[field] &&
this.fieldNeedsMigration(
host[field],
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);
for (const credential of sshCredentials) {
const sensitiveFields =
this.getSensitiveFieldsForTable("ssh_credentials");
const credentialPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
if (
credential[field] &&
this.fieldNeedsMigration(
credential[field],
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) {
if (
user[field] &&
this.fieldNeedsMigration(user[field], 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: [] };
}
}
}
-257
View File
@@ -1,257 +0,0 @@
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]: any;
}
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"];
class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
private logCounts = new Map<string, { count: number; lastLog: number }>();
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 logger = systemLogger;
-157
View File
@@ -1,157 +0,0 @@
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
type TableName = "users" | "ssh_data" | "ssh_credentials";
class SimpleDBOps {
static async insert<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
data: T,
userId: string,
): Promise<T> {
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 (!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<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return [];
}
const results = await query;
const decryptedResults = DataCrypto.decryptRecords(
tableName,
results,
userId,
userDataKey,
);
return decryptedResults;
}
static async selectOne<T extends Record<string, any>>(
query: any,
tableName: TableName,
userId: string,
): Promise<T | undefined> {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
return undefined;
}
const result = await query;
if (!result) return undefined;
const decryptedResult = DataCrypto.decryptRecord(
tableName,
result,
userId,
userDataKey,
);
return decryptedResult;
}
static async update<T extends Record<string, any>>(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
data: Partial<T>,
userId: string,
): Promise<T[]> {
const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedData = DataCrypto.encryptRecord(
tableName,
data,
userId,
userDataKey,
);
const result = await getDb()
.update(table)
.set(encryptedData)
.where(where)
.returning();
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
const decryptedResults = DataCrypto.decryptRecords(
tableName,
result,
userId,
userDataKey,
);
return decryptedResults as T[];
}
static async delete(
table: SQLiteTable<any>,
tableName: TableName,
where: any,
userId: string,
): Promise<any[]> {
const result = await getDb().delete(table).where(where).returning();
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
return result;
}
static async healthCheck(userId: string): Promise<boolean> {
return DataCrypto.canUserAccessData(userId);
}
static isUserDataUnlocked(userId: string): boolean {
return DataCrypto.getUserDataKey(userId) !== null;
}
static async selectEncrypted(
query: any,
tableName: TableName,
): Promise<any[]> {
const results = await query;
return results;
}
}
export { SimpleDBOps, type TableName };
-418
View File
@@ -1,418 +0,0 @@
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 (error) {
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 (error) {}
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 (error) {}
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 (error) {
publicKey = "";
}
useSSH2 = true;
}
} catch (error) {}
}
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 (fallbackError) {}
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 (error) {
return "unknown";
}
}
export function getFriendlyKeyTypeName(keyType: string): string {
const keyTypeMap: Record<string, string> = {
"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",
};
}
}
-263
View File
@@ -1,263 +0,0 @@
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 constructor() {}
static getInstance(): SystemCrypto {
if (!this.instance) {
this.instance = new SystemCrypto();
}
return this.instance;
}
async initializeJWTSecret(): Promise<void> {
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];
return;
}
} catch {}
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<string> {
if (!this.jwtSecret) {
await this.initializeJWTSecret();
}
return this.jwtSecret!;
}
async initializeDatabaseKey(): Promise<void> {
try {
const envKey = process.env.DATABASE_KEY;
if (envKey && envKey.length >= 64) {
this.databaseKey = Buffer.from(envKey, "hex");
return;
}
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
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;
}
} catch {}
await this.generateAndGuideDatabaseKey();
} catch (error) {
databaseLogger.error("Failed to initialize database key", error, {
operation: "db_key_init_failed",
});
throw new Error("Database key initialization failed");
}
}
async getDatabaseKey(): Promise<Buffer> {
if (!this.databaseKey) {
await this.initializeDatabaseKey();
}
return this.databaseKey!;
}
async initializeInternalAuthToken(): Promise<void> {
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 {}
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<string> {
if (!this.internalAuthToken) {
await this.initializeInternalAuthToken();
}
return this.internalAuthToken!;
}
private async generateAndGuideUser(): Promise<void> {
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<void> {
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<void> {
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",
},
);
}
async validateJWTSecret(): Promise<boolean> {
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<void> {
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 };
-531
View File
@@ -1,531 +0,0 @@
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;
lastActivity: number;
expiresAt: number;
}
class UserCrypto {
private static instance: UserCrypto;
private userSessions: Map<string, UserSession> = 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 static readonly SESSION_DURATION = 24 * 60 * 60 * 1000;
private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000;
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<void> {
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): Promise<void> {
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),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
}
async authenticateUser(userId: string, password: string): Promise<boolean> {
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),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
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): Promise<boolean> {
try {
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
await this.setupOIDCUserEncryption(userId);
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);
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),
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
DEK.fill(0);
return true;
} catch (error) {
await this.setupOIDCUserEncryption(userId);
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;
}
if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) {
this.userSessions.delete(userId);
session.dataKey.fill(0);
if (this.sessionExpiredCallback) {
this.sessionExpiredCallback(userId);
}
return null;
}
session.lastActivity = now;
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<boolean> {
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);
const dekCopy = Buffer.from(DEK);
const now = Date.now();
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: dekCopy,
lastActivity: now,
expiresAt: now + UserCrypto.SESSION_DURATION,
});
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<boolean> {
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;
}
}
private async validatePassword(
userId: string,
password: string,
): Promise<boolean> {
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 (error) {
return false;
}
}
private cleanupExpiredSessions(): void {
const now = Date.now();
const expiredUsers: string[] = [];
for (const [userId, session] of this.userSessions.entries()) {
if (
now > session.expiresAt ||
now - session.lastActivity > UserCrypto.MAX_INACTIVITY
) {
session.dataKey.fill(0);
expiredUsers.push(userId);
}
}
expiredUsers.forEach((userId) => {
this.userSessions.delete(userId);
});
}
private async generateKEKSalt(): Promise<KEKSalt> {
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<void> {
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<KEKSalt | null> {
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 (error) {
return null;
}
}
private async storeEncryptedDEK(
userId: string,
encryptedDEK: EncryptedDEK,
): Promise<void> {
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<EncryptedDEK | null> {
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 (error) {
return null;
}
}
}
export { UserCrypto, type KEKSalt, type EncryptedDEK };

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