Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6940f4e9bb | |||
| 10bc491a9f | |||
| 9aa83c24ed | |||
| 54f03d73ce | |||
| b0062343c7 | |||
| 09677caa26 | |||
| 7dc1bce272 | |||
| 58ba96654a | |||
| 0b00c26f8a | |||
| 9ff199ea96 | |||
| 0d464cdf56 | |||
| 963e54bf15 | |||
| 62262e5bf3 | |||
| e800064f05 | |||
| 713d43fc52 | |||
| a6295e5e7e | |||
| 2038a84c15 | |||
| cf8fff572a | |||
| cf5f0c41eb | |||
| b43ca54fa0 | |||
| 08dbfdaf8f | |||
| ba502de450 | |||
| 226cdcb7a0 | |||
| a9d510aba4 | |||
| 5c5fe57dad | |||
| 1addd43039 | |||
| 946f5daaa9 | |||
| e42c8c9788 | |||
| a6b03aa792 | |||
| 175225dcd7 | |||
| f2918e02f6 | |||
| cb18a0cd87 | |||
| 98a45e9161 | |||
| 95c2994b3c | |||
| 0b302fc860 | |||
| c5429ff2e8 | |||
| 9ab46cddd4 | |||
| 128ecbea04 | |||
| 9f7331d72a | |||
| 968e0cfeea | |||
| c7e06f422d | |||
| d799b8fe42 | |||
| e058892118 | |||
| 33060eb1e4 | |||
| 3b0eddca27 | |||
| 8afadf6176 | |||
| 002683a2f0 | |||
| 24dcdc6751 | |||
| bbaa9bf226 | |||
| 37e314edff | |||
| b2f5459817 | |||
| cd53d15a04 | |||
| a17e72eec8 | |||
| 82b57a6af5 | |||
| 3c474d3b52 | |||
| 15e1fd215e | |||
| 4d8263a788 | |||
| 2061ac5e3b | |||
| e1f9251186 | |||
| b60924b9c7 | |||
| bd844c7f11 | |||
| ec64bb401b | |||
| 963ac01671 | |||
| 50c07c2f3a | |||
| 202de2123b | |||
| f677a54551 | |||
| 0554f9c0fc | |||
| 1901c09a70 | |||
| 8229048e4c | |||
| d7ca5eccf3 | |||
| 1adb64524c | |||
| 56ed93bb88 | |||
| 861194d8d1 | |||
| 74ce228fe1 | |||
| 17d2e65187 | |||
| 376fbb1b66 | |||
| e68a508dec | |||
| ad7532e88d | |||
| c2405b0df2 | |||
| d422a9ad25 | |||
| ebf00630fd | |||
| 0bb4f2fb0b | |||
| bc49d646b3 | |||
| 0f764dfb98 | |||
| 2fa968662e | |||
| b5da3994b0 | |||
| 1820f36155 | |||
| 87107b8cc2 | |||
| 0cfde2410e | |||
| 25f8ce74ea | |||
| 02acc72bf6 | |||
| a81b4834ce | |||
| a2a441cdca | |||
| 4b927decce | |||
| bda3fe9b31 | |||
| fe09966196 | |||
| bc14f803a2 | |||
| b3e43a45af | |||
| 95573f7e37 | |||
| 0ed7d7a083 | |||
| 474965835f | |||
| 1f75dc0bd2 | |||
| 7b545367dc | |||
| 1a0d224ae0 | |||
| 848b388734 | |||
| ea2343551d | |||
| d46ddfe6ee | |||
| a407bf15f7 | |||
| 3721edc128 | |||
| 0900017218 | |||
| f14db79784 | |||
| 870cd9245e | |||
| 246bb8fb26 | |||
| 1139160e48 | |||
| b6a3f881a8 |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"],
|
||||
"rules": {
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"feat",
|
||||
"fix",
|
||||
"docs",
|
||||
"style",
|
||||
"refactor",
|
||||
"perf",
|
||||
"test",
|
||||
"chore",
|
||||
"revert"
|
||||
]
|
||||
],
|
||||
"subject-case": [0]
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
README.md
|
||||
README-CN.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
|
||||
repo-images/
|
||||
|
||||
uploads/
|
||||
|
||||
electron/
|
||||
electron-builder.json
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
logs
|
||||
*.log
|
||||
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
jspm_packages/
|
||||
|
||||
.npm
|
||||
|
||||
.eslintcache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
.node_repl_history
|
||||
|
||||
*.tgz
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
.next
|
||||
|
||||
.nuxt
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
.serverless
|
||||
|
||||
.fusebox/
|
||||
|
||||
.dynamodb/
|
||||
|
||||
.tern-port
|
||||
@@ -1,14 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,31 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
*.bash text eol=lf
|
||||
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
@@ -1 +0,0 @@
|
||||
github: [LukeGus]
|
||||
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Support Center
|
||||
url: https://github.com/Termix-SSH/Support/issues
|
||||
about: Report any feature requests or bugs in the support center
|
||||
- name: Discord
|
||||
url: https://discord.gg/jVQGdvHDrf
|
||||
about: Official Termix Discord server for general discussion and quick support
|
||||
@@ -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"
|
||||
@@ -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/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- '.gitignore'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: "Custom tag name for the Docker image"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- 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: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
node_modules
|
||||
*/*/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine Docker image tag
|
||||
run: |
|
||||
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.event.inputs.tag_name }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and Push Multi-Arch Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ 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
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Notify via ntfy
|
||||
if: success()
|
||||
run: |
|
||||
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()
|
||||
uses: quartx-analytics/ghcr-cleaner@v1
|
||||
with:
|
||||
owner-type: user
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
repository-owner: ${{ github.repository_owner }}
|
||||
delete-untagged: true
|
||||
|
||||
- name: Cleanup Docker Images Locally
|
||||
if: always()
|
||||
run: |
|
||||
docker image prune -af
|
||||
docker system prune -af --volumes
|
||||
@@ -1,94 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
required: true
|
||||
build_type:
|
||||
description: "Build type"
|
||||
required: true
|
||||
default: "Development"
|
||||
type: choice
|
||||
options:
|
||||
- Development
|
||||
- Production
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
BUILD_TYPE=${{ github.event.inputs.build_type }}
|
||||
|
||||
TAGS=()
|
||||
ALL_TAGS=()
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("release-$VERSION" "latest")
|
||||
for tag in "${TAGS[@]}"; do
|
||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
||||
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
|
||||
done
|
||||
else
|
||||
TAGS+=("dev-$VERSION")
|
||||
for tag in "${TAGS[@]}"; do
|
||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
||||
done
|
||||
fi
|
||||
|
||||
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: lukegus
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub (prod only)
|
||||
if: ${{ github.event.inputs.build_type == 'Production' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: bugattiguy527
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
outputs: type=registry,compression=gzip,compression-level=9
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
docker image prune -af
|
||||
docker system prune -af --volumes
|
||||
@@ -1,810 +0,0 @@
|
||||
name: Build and Push Electron App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Platform to build for"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- linux
|
||||
- macos
|
||||
artifact_destination:
|
||||
description: "What to do with the built app"
|
||||
required: true
|
||||
default: "file"
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- file
|
||||
- release
|
||||
- submit
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
$maxAttempts = 3
|
||||
$attempt = 1
|
||||
while ($attempt -le $maxAttempts) {
|
||||
try {
|
||||
npm ci
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq $maxAttempts) {
|
||||
Write-Error "npm ci failed after $maxAttempts attempts"
|
||||
exit 1
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
$attempt++
|
||||
}
|
||||
}
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
run: |
|
||||
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
||||
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Build Windows (All Architectures)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32
|
||||
|
||||
- name: List release files
|
||||
run: |
|
||||
dir release
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_nsis
|
||||
path: release/termix_windows_x64_nsis.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_nsis
|
||||
path: release/termix_windows_ia32_nsis.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows x64 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_msi
|
||||
path: release/termix_windows_x64_msi.msi
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_msi
|
||||
path: release/termix_windows_ia32_msi.msi
|
||||
retention-days: 30
|
||||
|
||||
- name: Create Windows x64 Portable zip
|
||||
if: hashFiles('release/win-unpacked/*') != ''
|
||||
run: |
|
||||
Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "termix_windows_x64_portable.zip"
|
||||
|
||||
- name: Create Windows ia32 Portable zip
|
||||
if: hashFiles('release/win-ia32-unpacked/*') != ''
|
||||
run: |
|
||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
||||
|
||||
- name: Upload Windows x64 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_portable
|
||||
path: termix_windows_x64_portable.zip
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('termix_windows_ia32_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_portable
|
||||
path: termix_windows_ia32_portable.zip
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install system dependencies for AppImage
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfuse2
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for i in 1 2 3;
|
||||
do
|
||||
if npm ci; then
|
||||
break
|
||||
else
|
||||
if [ $i -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
npm install --force @rollup/rollup-linux-x64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||
|
||||
- name: Build Linux x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEBUG: electron-builder
|
||||
run: npm run build && npx electron-builder --linux --x64
|
||||
|
||||
- name: Build Linux arm64 and armv7l
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx electron-builder --linux --arm64 --armv7l
|
||||
|
||||
- name: Rename Linux artifacts for consistency
|
||||
run: |
|
||||
cd release
|
||||
|
||||
if [ -f "termix_linux_amd64_deb.deb" ]; then
|
||||
mv "termix_linux_amd64_deb.deb" "termix_linux_x64_deb.deb"
|
||||
fi
|
||||
|
||||
if [ -f "termix_linux_x86_64_appimage.AppImage" ]; then
|
||||
mv "termix_linux_x86_64_appimage.AppImage" "termix_linux_x64_appimage.AppImage"
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
- name: List release files
|
||||
run: |
|
||||
ls -la release/
|
||||
|
||||
- name: Debug electron-builder output
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "release/builder-debug.yml" ]; then
|
||||
cat release/builder-debug.yml
|
||||
fi
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_appimage
|
||||
path: release/termix_linux_x64_appimage.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_appimage
|
||||
path: release/termix_linux_arm64_appimage.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_appimage
|
||||
path: release/termix_linux_armv7l_appimage.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux x64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_deb
|
||||
path: release/termix_linux_x64_deb.deb
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_deb
|
||||
path: release/termix_linux_arm64_deb.deb
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_deb
|
||||
path: release/termix_linux_armv7l_deb.deb
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux x64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_portable
|
||||
path: release/termix_linux_x64_portable.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_portable
|
||||
path: release/termix_linux_arm64_portable.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_portable
|
||||
path: release/termix_linux_armv7l_portable.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for i in 1 2 3;
|
||||
do
|
||||
if npm ci; then
|
||||
break
|
||||
else
|
||||
if [ $i -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
npm install --force @rollup/rollup-darwin-arm64
|
||||
npm install dmg-license
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
|
||||
echo "has_certs=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificates
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
|
||||
MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
|
||||
MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
|
||||
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
|
||||
|
||||
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
|
||||
fi
|
||||
|
||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
|
||||
if [ -f "$INSTALLER_CERT_PATH" ]; then
|
||||
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
fi
|
||||
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
- name: Build macOS App Store Package
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
|
||||
- name: Clean up MAS keychain before DMG build
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
|
||||
- name: Check for Developer ID Certificates
|
||||
id: check_dev_id_certs
|
||||
run: |
|
||||
if [ -n "${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.DEVELOPER_ID_P12_PASSWORD }}" ]; then
|
||||
echo "has_dev_id_certs=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Developer ID Certificates
|
||||
if: steps.check_dev_id_certs.outputs.has_dev_id_certs == 'true'
|
||||
env:
|
||||
DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
|
||||
DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
|
||||
MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
DEV_CERT_PATH=$RUNNER_TEMP/dev_certificate.p12
|
||||
DEV_INSTALLER_CERT_PATH=$RUNNER_TEMP/dev_installer_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/dev-signing.keychain-db
|
||||
|
||||
echo -n "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode -o $DEV_CERT_PATH
|
||||
|
||||
if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||
echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $DEV_INSTALLER_CERT_PATH
|
||||
fi
|
||||
|
||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
security import $DEV_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
|
||||
if [ -f "$DEV_INSTALLER_CERT_PATH" ]; then
|
||||
security import $DEV_INSTALLER_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
fi
|
||||
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
- name: Build macOS DMG
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
|
||||
npm run build
|
||||
fi
|
||||
export GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
|
||||
- name: List release directory
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
run: |
|
||||
ls -R release/ || echo "Release directory not found"
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termix_macos_universal_mas
|
||||
path: release/termix_macos_universal_mas.pkg
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload macOS Universal DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_universal_dmg
|
||||
path: release/termix_macos_universal_dmg.dmg
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload macOS x64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_x64_dmg
|
||||
path: release/termix_macos_x64_dmg.dmg
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload macOS arm64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_arm64_dmg
|
||||
path: release/termix_macos_arm64_dmg.dmg
|
||||
retention-days: 30
|
||||
|
||||
- name: Check for App Store Connect API credentials
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
id: check_asc_creds
|
||||
run: |
|
||||
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
||||
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Ruby for Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: false
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
|
||||
- name: Deploy to App Store Connect (TestFlight)
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
run: |
|
||||
PKG_FILE=$(find release -name "*.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||
|
||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||
--type macos \
|
||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
security delete-keychain $RUNNER_TEMP/dev-signing.keychain-db || true
|
||||
|
||||
submit-to-chocolatey:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-windows]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: |
|
||||
$VERSION = (Get-Content package.json | ConvertFrom-Json).version
|
||||
echo "version=$VERSION" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Download Windows x64 MSI artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_windows_x64_msi
|
||||
path: artifact
|
||||
|
||||
- name: Get MSI file info
|
||||
id: msi-info
|
||||
run: |
|
||||
$VERSION = "${{ steps.package-version.outputs.version }}"
|
||||
$MSI_FILE = Get-ChildItem -Path artifact -Filter "*.msi" | Select-Object -First 1
|
||||
$MSI_NAME = $MSI_FILE.Name
|
||||
$CHECKSUM = (Get-FileHash -Path $MSI_FILE.FullName -Algorithm SHA256).Hash
|
||||
|
||||
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Chocolatey package
|
||||
run: |
|
||||
$VERSION = "${{ steps.package-version.outputs.version }}"
|
||||
$CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
|
||||
$MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"
|
||||
|
||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "choco-build"
|
||||
Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force
|
||||
|
||||
$installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8
|
||||
$installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL
|
||||
$installScript = $installScript -replace 'CHECKSUM_PLACEHOLDER', $CHECKSUM
|
||||
[System.IO.File]::WriteAllText("$PWD\choco-build\tools\chocolateyinstall.ps1", $installScript, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$nuspec = Get-Content "choco-build\termix-ssh.nuspec" -Raw -Encoding UTF8
|
||||
$nuspec = $nuspec -replace 'VERSION_PLACEHOLDER', $VERSION
|
||||
[System.IO.File]::WriteAllText("$PWD\choco-build\termix-ssh.nuspec", $nuspec, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
- name: Install Chocolatey
|
||||
run: |
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
|
||||
- name: Pack Chocolatey package
|
||||
run: |
|
||||
cd choco-build
|
||||
choco pack termix-ssh.nuspec
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
- name: Check for Chocolatey API Key
|
||||
id: check_choco_key
|
||||
run: |
|
||||
if ("${{ secrets.CHOCOLATEY_API_KEY }}" -ne "") {
|
||||
echo "has_key=true" >> $env:GITHUB_OUTPUT
|
||||
}
|
||||
|
||||
- name: Push to Chocolatey
|
||||
if: steps.check_choco_key.outputs.has_key == 'true'
|
||||
run: |
|
||||
$VERSION = "${{ steps.package-version.outputs.version }}"
|
||||
cd choco-build
|
||||
choco apikey --key "${{ secrets.CHOCOLATEY_API_KEY }}" --source https://push.chocolatey.org/
|
||||
|
||||
try {
|
||||
choco push "termix-ssh.$VERSION.nupkg" --source https://push.chocolatey.org/
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
} else {
|
||||
throw "Chocolatey push failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
- name: Upload Chocolatey package as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: chocolatey-package
|
||||
path: choco-build/*.nupkg
|
||||
retention-days: 30
|
||||
|
||||
submit-to-flatpak:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-linux]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Linux x64 AppImage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_linux_x64_appimage
|
||||
path: artifact-x64
|
||||
|
||||
- name: Download Linux arm64 AppImage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_linux_arm64_appimage
|
||||
path: artifact-arm64
|
||||
|
||||
- name: Get AppImage file info
|
||||
id: appimage-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
|
||||
APPIMAGE_X64_FILE=$(find artifact-x64 -name "*.AppImage" -type f | head -n 1)
|
||||
APPIMAGE_X64_NAME=$(basename "$APPIMAGE_X64_FILE")
|
||||
CHECKSUM_X64=$(sha256sum "$APPIMAGE_X64_FILE" | awk '{print $1}')
|
||||
|
||||
APPIMAGE_ARM64_FILE=$(find artifact-arm64 -name "*.AppImage" -type f | head -n 1)
|
||||
APPIMAGE_ARM64_NAME=$(basename "$APPIMAGE_ARM64_FILE")
|
||||
CHECKSUM_ARM64=$(sha256sum "$APPIMAGE_ARM64_FILE" | awk '{print $1}')
|
||||
|
||||
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
|
||||
echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install ImageMagick for icon generation
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y imagemagick
|
||||
|
||||
- name: Prepare Flatpak submission files
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
|
||||
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
|
||||
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
|
||||
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
|
||||
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
|
||||
|
||||
mkdir -p flatpak-submission
|
||||
|
||||
cp flatpak/com.karmaa.termix.yml flatpak-submission/
|
||||
cp flatpak/com.karmaa.termix.desktop flatpak-submission/
|
||||
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
|
||||
cp flatpak/flathub.json flatpak-submission/
|
||||
|
||||
cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
|
||||
convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: List submission files
|
||||
run: |
|
||||
ls -la flatpak-submission/
|
||||
|
||||
- name: Upload Flatpak submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: flatpak-submission
|
||||
path: flatpak-submission/*
|
||||
retention-days: 30
|
||||
|
||||
submit-to-homebrew:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-macos]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download macOS Universal DMG artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: termix_macos_universal_dmg
|
||||
path: artifact
|
||||
|
||||
- name: Get DMG file info
|
||||
id: dmg-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
DMG_FILE=$(find artifact -name "*.dmg" -type f | head -n 1)
|
||||
DMG_NAME=$(basename "$DMG_FILE")
|
||||
CHECKSUM=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}')
|
||||
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Homebrew submission files
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
CHECKSUM="${{ steps.dmg-info.outputs.checksum }}"
|
||||
DMG_NAME="${{ steps.dmg-info.outputs.dmg_name }}"
|
||||
|
||||
mkdir -p homebrew-submission/Casks/t
|
||||
|
||||
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Verify Cask syntax
|
||||
run: |
|
||||
if ! command -v brew &> /dev/null; then
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
fi
|
||||
|
||||
ruby -c homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: List submission files
|
||||
run: |
|
||||
find homebrew-submission -type f
|
||||
|
||||
- name: Upload Homebrew submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: homebrew-submission
|
||||
path: homebrew-submission/*
|
||||
retention-days: 30
|
||||
|
||||
upload-to-release:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.event.inputs.artifact_destination == 'release'
|
||||
needs: [build-windows, build-linux, build-macos]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Get latest release tag
|
||||
id: get_release
|
||||
run: |
|
||||
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Display artifact structure
|
||||
run: |
|
||||
ls -R artifacts/
|
||||
|
||||
- name: Upload artifacts to latest release
|
||||
run: |
|
||||
cd artifacts
|
||||
for dir in */; do
|
||||
cd "$dir"
|
||||
for file in *;
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
|
||||
fi
|
||||
done
|
||||
cd ..
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev-*]
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
- name: Run ESLint
|
||||
run: npx eslint .
|
||||
|
||||
- name: Run Prettier check
|
||||
run: npx prettier --check .
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -1,3 +1,4 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -11,6 +12,7 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
@@ -20,11 +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
|
||||
/.mcp.json
|
||||
/nul
|
||||
/.vscode/
|
||||
.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 +0,0 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
@@ -1 +0,0 @@
|
||||
npx lint-staged
|
||||
@@ -1,18 +0,0 @@
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
dist-ssr
|
||||
release
|
||||
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
db
|
||||
|
||||
.env
|
||||
|
||||
*.min.js
|
||||
*.min.css
|
||||
openapi.json
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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/Termix-SSH/Termix
|
||||
```
|
||||
2. Install the dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running the development server
|
||||
|
||||
Run the following commands:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
npm run dev:backend
|
||||
```
|
||||
|
||||
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. **Fork the repository**: Click the "Fork" button at the top right of
|
||||
the [repository page](https://github.com/Termix-SSH/Termix).
|
||||
2. **Create a new branch**:
|
||||
```sh
|
||||
git checkout -b feature/my-new-feature
|
||||
```
|
||||
3. **Make your changes**: Implement your feature, fix, or improvement.
|
||||
4. **Commit your changes**:
|
||||
```sh
|
||||
git commit -m "Feature request my new feature"
|
||||
```
|
||||
5. **Push to your fork**:
|
||||
```sh
|
||||
git push origin feature/my-feature-request
|
||||
```
|
||||
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow the existing code style. Use Tailwind CSS with shadcn components.
|
||||
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
|
||||
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
|
||||
- Include meaningful commit messages.
|
||||
- Link related issues when applicable.
|
||||
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
|
||||
|
||||
## Color Scheme
|
||||
|
||||
### Background Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
|
||||
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
|
||||
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
|
||||
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
|
||||
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
|
||||
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
|
||||
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
|
||||
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
|
||||
|
||||
### Element-Specific Backgrounds
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
|
||||
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
|
||||
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
|
||||
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
|
||||
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
|
||||
|
||||
### Border Colors
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
|
||||
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
|
||||
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
|
||||
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
|
||||
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
|
||||
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
|
||||
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
|
||||
|
||||
### Interactive States
|
||||
|
||||
| CSS Variable | Color Value | Usage | Description |
|
||||
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
|
||||
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
|
||||
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
|
||||
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
|
||||
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
|
||||
|
||||
## Support
|
||||
|
||||
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
||||
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel, however, response times may be longer.
|
||||
@@ -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.
|
||||
@@ -1,154 +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>
|
||||
|
||||

|
||||

|
||||

|
||||
<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>
|
||||
|
||||
#### 核心技术
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/Termix-SSH/Termix">
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
</p>
|
||||
|
||||
如果你愿意,可以在这里支持这个项目!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# 概览
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Termix-SSH/Termix">
|
||||
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||
</p>
|
||||
|
||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||
提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
||||
|
||||
# 功能
|
||||
|
||||
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能
|
||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件
|
||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||
- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面
|
||||
- **语言** - 内置支持英语、中文、德语和葡萄牙语
|
||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
|
||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||
- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。
|
||||
|
||||
# 计划功能
|
||||
|
||||
查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
|
||||
# 安装
|
||||
|
||||
支持的设备:
|
||||
|
||||
- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)
|
||||
- Windows(x64/ia32)
|
||||
- 便携版
|
||||
- MSI 安装程序
|
||||
- Chocolatey 软件包管理器(即将推出)
|
||||
- Linux(x64/ia32)
|
||||
- 便携版
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak(即将推出)
|
||||
- macOS(x64/ia32 on v12.0+)
|
||||
- Apple App Store(即将推出)
|
||||
- DMG
|
||||
- Homebrew(即将推出)
|
||||
- iOS/iPadOS(v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
- Android(v7.0+)
|
||||
- Google Play 商店
|
||||
- APK
|
||||
|
||||
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
```
|
||||
|
||||
# 支持
|
||||
|
||||
如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
||||
请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持
|
||||
频道,但响应时间可能较长。
|
||||
|
||||
# 展示
|
||||
|
||||
<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>
|
||||
你的浏览器不支持 video 标签。
|
||||
</video>
|
||||
</p>
|
||||
视频和图像可能已过时。
|
||||
|
||||
# 许可证
|
||||
|
||||
根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。
|
||||
@@ -1,157 +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>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
<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
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/Termix-SSH/Termix">
|
||||
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||
<a href="https://github.com/LukeGus/Termix">
|
||||
<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!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
[](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/Termix-SSH/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 multi-platform
|
||||
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. Termix is the perfect
|
||||
free and self-hosted alternative to Termius available for all platforms.
|
||||
> [!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) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
|
||||
- **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 deployment of SSH keys
|
||||
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
|
||||
- **Dashboard** - View server information at a glance on your dashboard
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
- 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/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
- 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 on any platform like Chrome, Safari, and Firefox)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager (coming soon)
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak (coming soon)
|
||||
- macOS (x64/ia32 on v12.0+)
|
||||
- Apple App Store (coming soon)
|
||||
- DMG
|
||||
- Homebrew (coming soon)
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
||||
a sample Docker Compose file here:
|
||||
|
||||
```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 or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
|
||||
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel, however, response times may be longer.
|
||||
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>
|
||||
Videos and images may be out of date.
|
||||

|
||||

|
||||
|
||||
# License
|
||||
|
||||
Distributed under the Apache License Version 2.0. See LICENSE for more information.
|
||||
Distributed under the MIT license. See LICENSE for more information.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,31 +0,0 @@
|
||||
const { notarize } = require('@electron/notarize');
|
||||
|
||||
exports.default = async function notarizing(context) {
|
||||
const { electronPlatformName, appOutDir } = context;
|
||||
|
||||
if (electronPlatformName !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const appleId = process.env.APPLE_ID;
|
||||
const appleIdPassword = process.env.APPLE_ID_PASSWORD;
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
|
||||
if (!appleId || !appleIdPassword || !teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
|
||||
try {
|
||||
await notarize({
|
||||
appBundleId: 'com.karmaa.termix',
|
||||
appPath: `${appOutDir}/${appName}.app`,
|
||||
appleId: appleId,
|
||||
appleIdPassword: appleIdPassword,
|
||||
teamId: teamId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Notarization failed:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>termix-ssh</id>
|
||||
<version>VERSION_PLACEHOLDER</version>
|
||||
<packageSourceUrl>https://github.com/Termix-SSH/Termix</packageSourceUrl>
|
||||
<owners>bugattiguy527</owners>
|
||||
<title>Termix SSH</title>
|
||||
<authors>bugattiguy527</authors>
|
||||
<projectUrl>https://github.com/Termix-SSH/Termix</projectUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png</iconUrl>
|
||||
<licenseUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/refs/heads/main/LICENSE</licenseUrl>
|
||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||
<projectSourceUrl>https://github.com/Termix-SSH/Termix</projectSourceUrl>
|
||||
<docsUrl>https://docs.termix.site/install</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
|
||||
<tags>docker ssh self-hosted file-management ssh-tunnel termix server-management terminal</tags>
|
||||
<summary>Termix is a web-based server management platform with SSH terminal, tunneling, and file editing capabilities.</summary>
|
||||
<description>
|
||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface.
|
||||
|
||||
Termix offers:
|
||||
- SSH terminal access
|
||||
- SSH tunneling capabilities
|
||||
- Remote file management
|
||||
- Server monitoring and management
|
||||
|
||||
This package installs the desktop application version of Termix.
|
||||
</description>
|
||||
<releaseNotes>https://github.com/Termix-SSH/Termix/releases</releaseNotes>
|
||||
</metadata>
|
||||
<files>
|
||||
<file src="tools\**" target="tools" />
|
||||
</files>
|
||||
</package>
|
||||
@@ -1,20 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$packageName = 'termix-ssh'
|
||||
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||
$url64 = 'DOWNLOAD_URL_PLACEHOLDER'
|
||||
$checksum64 = 'CHECKSUM_PLACEHOLDER'
|
||||
$checksumType64 = 'sha256'
|
||||
|
||||
$packageArgs = @{
|
||||
packageName = $packageName
|
||||
fileType = 'msi'
|
||||
url64bit = $url64
|
||||
softwareName = 'Termix*'
|
||||
checksum64 = $checksum64
|
||||
checksumType64 = $checksumType64
|
||||
silentArgs = "/qn /norestart /l*v `"$($env:TEMP)\$($packageName).$($env:chocolateyPackageVersion).MsiInstall.log`""
|
||||
validExitCodes = @(0, 3010, 1641)
|
||||
}
|
||||
|
||||
Install-ChocolateyPackage @packageArgs
|
||||
@@ -1,33 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$packageName = 'termix-ssh'
|
||||
$softwareName = 'Termix*'
|
||||
$installerType = 'msi'
|
||||
|
||||
$silentArgs = '/qn /norestart'
|
||||
$validExitCodes = @(0, 3010, 1605, 1614, 1641)
|
||||
|
||||
[array]$key = Get-UninstallRegistryKey -SoftwareName $softwareName
|
||||
|
||||
if ($key.Count -eq 1) {
|
||||
$key | % {
|
||||
$file = "$($_.UninstallString)"
|
||||
|
||||
if ($installerType -eq 'msi') {
|
||||
$silentArgs = "$($_.PSChildName) $silentArgs"
|
||||
$file = ''
|
||||
}
|
||||
|
||||
Uninstall-ChocolateyPackage -PackageName $packageName `
|
||||
-FileType $installerType `
|
||||
-SilentArgs "$silentArgs" `
|
||||
-ValidExitCodes $validExitCodes `
|
||||
-File "$file"
|
||||
}
|
||||
} elseif ($key.Count -eq 0) {
|
||||
Write-Warning "$packageName has already been uninstalled by other means."
|
||||
} elseif ($key.Count -gt 1) {
|
||||
Write-Warning "$($key.Count) matches found!"
|
||||
Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
|
||||
$key | % {Write-Warning "- $($_.DisplayName)"}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,77 +1,107 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-slim AS deps
|
||||
# Stage 1: Build frontend
|
||||
FROM node:18-alpine 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 ./
|
||||
|
||||
RUN rm -rf node_modules package-lock.json && \
|
||||
npm install --ignore-scripts --force && \
|
||||
RUN npm ci --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
FROM deps AS frontend-builder
|
||||
WORKDIR /app
|
||||
|
||||
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 node:18-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm rebuild better-sqlite3 --force
|
||||
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:22-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --only=production --ignore-scripts --force && \
|
||||
npm rebuild better-sqlite3 bcryptjs --force && \
|
||||
RUN npm ci --only=production --force && \
|
||||
npm cache clean --force
|
||||
COPY src/backend/ ./src/backend/
|
||||
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:22-slim
|
||||
# Stage 3: Build bcrypt for Ubuntu
|
||||
FROM ubuntu:focal AS bcrypt-builder
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=18.x
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* && \
|
||||
apt-get clean && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
wget \
|
||||
ca-certificates \
|
||||
gnupg && \
|
||||
wget -qO- https://deb.nodesource.com/setup_${NODE_VERSION} | bash - && \
|
||||
apt-get update && \
|
||||
apt-get install -y nodejs && \
|
||||
npm ci --only=production bcrypt --force && \
|
||||
npm cache clean --force && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
# Final stage
|
||||
FROM ubuntu:focal
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
NODE_VERSION=18.x \
|
||||
MONGO_VERSION=4.4.24 \
|
||||
MONGO_URL=mongodb://localhost:27017/termix \
|
||||
MONGODB_DATA_DIR=/data/db \
|
||||
MONGODB_LOG_DIR=/var/log/mongodb \
|
||||
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
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
|
||||
# Create users first
|
||||
RUN groupadd -r mongodb && useradd -r -g mongodb mongodb \
|
||||
&& groupadd -r node && useradd -r -g node -m node
|
||||
|
||||
# Install all dependencies in one layer
|
||||
RUN rm -f /var/lib/apt/lists/lock /var/cache/apt/archives/lock /var/lib/dpkg/lock* && \
|
||||
apt-get clean && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
gnupg \
|
||||
gosu \
|
||||
nginx-light \
|
||||
wget && \
|
||||
# Add MongoDB 4.4 repository
|
||||
wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | apt-key add - && \
|
||||
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/4.4 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-4.4.list && \
|
||||
# Add MongoDB 5.0 repository
|
||||
wget -qO - https://www.mongodb.org/static/pgp/server-5.0.asc | apt-key add - && \
|
||||
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/5.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-5.0.list && \
|
||||
# Add NodeJS repository
|
||||
wget -qO- https://deb.nodesource.com/setup_${NODE_VERSION} | bash - && \
|
||||
apt-get update && \
|
||||
# Install MongoDB 4.4 and 5.0 packages
|
||||
apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
mongodb-org-server=${MONGO_VERSION} \
|
||||
mongodb-org-shell=${MONGO_VERSION} \
|
||||
mongodb-org-server=5.0.21 && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* && \
|
||||
mkdir -p /data/db /var/log/mongodb /var/run/mongodb && \
|
||||
chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb && \
|
||||
chmod 755 /data/db /var/log/mongodb /var/run/mongodb
|
||||
|
||||
# Setup nginx and frontend
|
||||
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
|
||||
RUN chown -R www-data:www-data /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 ci --only=production --ignore-scripts --force && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /tmp/*
|
||||
COPY --from=bcrypt-builder /app/node_modules/bcrypt /app/node_modules/bcrypt
|
||||
COPY --from=backend-builder /app/src/backend ./src/backend
|
||||
RUN chown -R node:node /app
|
||||
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
COPY --chown=node:node package.json ./
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
||||
VOLUME ["/data/db"]
|
||||
EXPOSE 8080 8081 8082 27017
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
CMD ["/entrypoint.sh"]
|
||||
CMD ["/entrypoint.sh"]
|
||||
@@ -1,121 +1,124 @@
|
||||
#!/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}
|
||||
# Create required directories and set permissions
|
||||
mkdir -p /data/db /var/log/mongodb /var/run/mongodb
|
||||
chown -R mongodb:mongodb /data/db /var/log/mongodb /var/run/mongodb
|
||||
chmod 755 /data/db /var/log/mongodb /var/run/mongodb
|
||||
|
||||
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..."
|
||||
# Function to check MongoDB version
|
||||
check_mongo_version() {
|
||||
echo "Checking MongoDB version..."
|
||||
if [ -f "/data/db/diagnostic.data/metrics.2" ] || [ -f "/data/db/WiredTiger.wt" ]; then
|
||||
echo "Existing MongoDB data detected, attempting migration..."
|
||||
|
||||
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
|
||||
# Clear any existing mongod lock file
|
||||
rm -f /tmp/mongodb-27017.sock
|
||||
rm -f /data/db/mongod.lock
|
||||
|
||||
# First, start MongoDB 5.0 to set compatibility version
|
||||
echo "Starting MongoDB 5.0 to set compatibility version..."
|
||||
gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --port 27017 --bind_ip 127.0.0.1 --config /etc/mongod.conf &
|
||||
MONGO_PID=$!
|
||||
|
||||
# Wait for MongoDB 5.0 to start
|
||||
echo "Waiting for MongoDB 5.0 to start..."
|
||||
MAX_TRIES=30
|
||||
COUNT=0
|
||||
while ! gosu mongodb mongo --quiet --eval "db.version()" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ $COUNT -ge $MAX_TRIES ]; then
|
||||
echo "Failed to start MongoDB 5.0 after $MAX_TRIES attempts"
|
||||
kill -9 $MONGO_PID 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Set compatibility version to 4.4
|
||||
echo "Setting feature compatibility version to 4.4..."
|
||||
if ! gosu mongodb mongo --quiet --eval 'db.adminCommand({setFeatureCompatibilityVersion: "4.4"})'; then
|
||||
echo "Failed to set feature compatibility version"
|
||||
kill -9 $MONGO_PID 2>/dev/null || true
|
||||
return 1
|
||||
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"
|
||||
# Shutdown MongoDB 5.0 cleanly
|
||||
echo "Shutting down MongoDB 5.0..."
|
||||
gosu mongodb mongo --quiet --eval "db.adminCommand({shutdown: 1})" || kill $MONGO_PID
|
||||
|
||||
# Wait for process to end
|
||||
while kill -0 $MONGO_PID 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run repair with MongoDB 4.4
|
||||
echo "Running repair with MongoDB 4.4..."
|
||||
gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --repair
|
||||
|
||||
return 0
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Try migration up to 3 times
|
||||
MAX_MIGRATION_ATTEMPTS=3
|
||||
MIGRATION_ATTEMPT=1
|
||||
|
||||
while [ $MIGRATION_ATTEMPT -le $MAX_MIGRATION_ATTEMPTS ]; do
|
||||
echo "Migration attempt $MIGRATION_ATTEMPT of $MAX_MIGRATION_ATTEMPTS"
|
||||
if check_mongo_version; then
|
||||
break
|
||||
fi
|
||||
MIGRATION_ATTEMPT=$((MIGRATION_ATTEMPT + 1))
|
||||
if [ $MIGRATION_ATTEMPT -le $MAX_MIGRATION_ATTEMPTS ]; then
|
||||
echo "Migration failed, waiting before retry..."
|
||||
sleep 5
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $MIGRATION_ATTEMPT -gt $MAX_MIGRATION_ATTEMPTS ]; then
|
||||
echo "Migration failed after $MAX_MIGRATION_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start MongoDB 4.4 normally
|
||||
echo "Starting MongoDB 4.4..."
|
||||
gosu mongodb /usr/bin/mongod --dbpath $MONGODB_DATA_DIR --logpath $MONGODB_LOG_DIR/mongodb.log --bind_ip 0.0.0.0 &
|
||||
MONGO_PID=$!
|
||||
|
||||
# Wait for MongoDB to be ready
|
||||
echo "Waiting for MongoDB to start..."
|
||||
MAX_TRIES=30
|
||||
COUNT=0
|
||||
while ! gosu mongodb mongo --quiet --eval "db.version()" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
COUNT=$((COUNT + 1))
|
||||
if [ $COUNT -ge $MAX_TRIES ]; then
|
||||
echo "Failed to start MongoDB. Checking logs:"
|
||||
cat $MONGODB_LOG_DIR/mongodb.log
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for MongoDB... (attempt $COUNT/$MAX_TRIES)"
|
||||
done
|
||||
echo "MongoDB started successfully"
|
||||
|
||||
# Start nginx
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
|
||||
# Start backend services
|
||||
echo "Starting backend services..."
|
||||
cd /app
|
||||
export NODE_ENV=production
|
||||
|
||||
if [ -f "package.json" ]; then
|
||||
VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||
if [ -n "$VERSION" ]; then
|
||||
export VERSION
|
||||
else
|
||||
echo "Warning: Could not extract version from package.json"
|
||||
fi
|
||||
else
|
||||
echo "Warning: package.json not found"
|
||||
fi
|
||||
# Start SSH service
|
||||
su -s /bin/bash node -c "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 database service
|
||||
su -s /bin/bash node -c "node src/backend/database.cjs" &
|
||||
|
||||
echo "All services started"
|
||||
|
||||
tail -f /dev/null
|
||||
# Keep container running and show logs
|
||||
tail -f $MONGODB_LOG_DIR/mongodb.log
|
||||
@@ -1,311 +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-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/sessions(/.*)?$ {
|
||||
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 ~ ^/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 ~ ^/snippets(/.*)?$ {
|
||||
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 ~ ^/terminal(/.*)?$ {
|
||||
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 ~ ^/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;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
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 ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,306 +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-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
# Serve the React app
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
location ~* \.map$ {
|
||||
return 404;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location ~ ^/users/sessions(/.*)?$ {
|
||||
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 ~ ^/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 ~ ^/snippets(/.*)?$ {
|
||||
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 ~ ^/terminal(/.*)?$ {
|
||||
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 ~ ^/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;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
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 ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{
|
||||
"appId": "com.karmaa.termix",
|
||||
"productName": "Termix",
|
||||
"publish": null,
|
||||
"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": true,
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64", "ia32"]
|
||||
},
|
||||
{
|
||||
"target": "msi",
|
||||
"arch": ["x64", "ia32"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.ico",
|
||||
"executableName": "Termix"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "termix_windows_${arch}_nsis.${ext}",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Termix",
|
||||
"uninstallDisplayName": "Termix"
|
||||
},
|
||||
"msi": {
|
||||
"artifactName": "termix_windows_${arch}_msi.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"artifactName": "termix_linux_${arch}_portable.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64", "arm64", "armv7l"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64", "arm64", "armv7l"]
|
||||
},
|
||||
{
|
||||
"target": "tar.gz",
|
||||
"arch": ["x64", "arm64", "armv7l"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.png",
|
||||
"category": "Development",
|
||||
"executableName": "termix",
|
||||
"maintainer": "Termix <mail@termix.site>",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Name": "Termix",
|
||||
"Comment": "A web-based server management platform",
|
||||
"Keywords": "terminal;ssh;server;management;",
|
||||
"StartupWMClass": "termix"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "termix_linux_${arch}_appimage.${ext}"
|
||||
},
|
||||
"deb": {
|
||||
"artifactName": "termix_linux_${arch}_deb.${ext}"
|
||||
},
|
||||
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "mas",
|
||||
"arch": "universal"
|
||||
},
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["universal", "x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.icns",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "termix_macos_${arch}_dmg.${ext}",
|
||||
"sign": true
|
||||
},
|
||||
"afterSign": "build/notarize.cjs",
|
||||
"mas": {
|
||||
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
|
||||
"entitlements": "build/entitlements.mas.plist",
|
||||
"entitlementsInherit": "build/entitlements.mas.inherit.plist",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"asarUnpack": ["**/*.node"],
|
||||
"type": "distribution",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
"extendInfo": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
ipcMain,
|
||||
dialog,
|
||||
Menu,
|
||||
} = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("--no-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-setuid-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-dev-shm-usage");
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("--disable-gpu");
|
||||
app.commandLine.appendSwitch("--disable-gpu-compositing");
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
||||
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
||||
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||
|
||||
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() {
|
||||
const appVersion = app.getVersion();
|
||||
const electronVersion = process.versions.electron;
|
||||
const platform =
|
||||
process.platform === "win32"
|
||||
? "Windows"
|
||||
: process.platform === "darwin"
|
||||
? "macOS"
|
||||
: "Linux";
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
icon: path.join(appRoot, "public", "icon.png"),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
partition: "persist:termix",
|
||||
allowRunningInsecureContent: true,
|
||||
webviewTag: true,
|
||||
offscreen: false,
|
||||
},
|
||||
show: true,
|
||||
});
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
|
||||
mainWindow.webContents.setUserAgent(customUserAgent);
|
||||
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
details.requestHeaders["X-Electron-App"] = "true";
|
||||
|
||||
details.requestHeaders["User-Agent"] = customUserAgent;
|
||||
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:5173");
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
const indexPath = path.join(appRoot, "dist", "index.html");
|
||||
mainWindow.loadFile(indexPath).catch((err) => {
|
||||
console.error("Failed to load file:", err);
|
||||
});
|
||||
}
|
||||
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
const headers = details.responseHeaders;
|
||||
|
||||
if (headers) {
|
||||
delete headers["x-frame-options"];
|
||||
delete headers["X-Frame-Options"];
|
||||
|
||||
if (headers["content-security-policy"]) {
|
||||
headers["content-security-policy"] = headers[
|
||||
"content-security-policy"
|
||||
]
|
||||
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
||||
.filter((value) => value.trim().length > 0);
|
||||
|
||||
if (headers["content-security-policy"].length === 0) {
|
||||
delete headers["content-security-policy"];
|
||||
}
|
||||
}
|
||||
if (headers["Content-Security-Policy"]) {
|
||||
headers["Content-Security-Policy"] = headers[
|
||||
"Content-Security-Policy"
|
||||
]
|
||||
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
||||
.filter((value) => value.trim().length > 0);
|
||||
|
||||
if (headers["Content-Security-Policy"].length === 0) {
|
||||
delete headers["Content-Security-Policy"];
|
||||
}
|
||||
}
|
||||
|
||||
if (headers["set-cookie"]) {
|
||||
headers["set-cookie"] = headers["set-cookie"].map((cookie) => {
|
||||
let modified = cookie.replace(
|
||||
/;\s*SameSite=Strict/gi,
|
||||
"; SameSite=None",
|
||||
);
|
||||
modified = modified.replace(
|
||||
/;\s*SameSite=Lax/gi,
|
||||
"; SameSite=None",
|
||||
);
|
||||
if (!modified.includes("SameSite=")) {
|
||||
modified += "; SameSite=None";
|
||||
}
|
||||
if (
|
||||
!modified.includes("Secure") &&
|
||||
details.url.startsWith("https")
|
||||
) {
|
||||
modified += "; Secure";
|
||||
}
|
||||
return modified;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
callback({ responseHeaders: headers });
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (mainWindow && !mainWindow.isVisible()) {
|
||||
mainWindow.show();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error(
|
||||
"Failed to load:",
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
console.log("Frontend loaded successfully");
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
const githubCache = new Map();
|
||||
const CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||
const cached = githubCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return {
|
||||
data: cached.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cached.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
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("get-setting", (event, key) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
const settings = JSON.parse(settingsData);
|
||||
return settings[key] !== undefined ? settings[key] : null;
|
||||
} catch (error) {
|
||||
console.error("Error reading setting:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("set-setting", (event, key, value) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const settingsPath = path.join(userDataPath, "settings.json");
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
||||
settings = JSON.parse(settingsData);
|
||||
}
|
||||
|
||||
settings[key] = value;
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error saving setting:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("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 };
|
||||
}
|
||||
});
|
||||
|
||||
function createMenu() {
|
||||
if (process.platform === "darwin") {
|
||||
const template = [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
{ type: "separator" },
|
||||
{ role: "front" },
|
||||
{ type: "separator" },
|
||||
{ role: "window" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createMenu();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -1,31 +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",
|
||||
|
||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
@@ -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,11 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Termix
|
||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||
Exec=termix %U
|
||||
Icon=com.karmaa.termix
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;Network;System;
|
||||
Keywords=ssh;terminal;server;management;tunnel;
|
||||
StartupWMClass=termix
|
||||
StartupNotify=true
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.karmaa.termix</id>
|
||||
<name>Termix</name>
|
||||
<summary>Web-based server management platform with SSH terminal, tunneling, and file editing</summary>
|
||||
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<developer_name>bugattiguy527</developer_name>
|
||||
|
||||
<description>
|
||||
<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.
|
||||
</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>SSH terminal access with full terminal emulation</li>
|
||||
<li>SSH tunneling capabilities for secure port forwarding</li>
|
||||
<li>Remote file management with editor support</li>
|
||||
<li>Server monitoring and management tools</li>
|
||||
<li>Self-hosted solution - keep your data private</li>
|
||||
<li>Modern, intuitive web interface</li>
|
||||
</ul>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.karmaa.termix.desktop</launchable>
|
||||
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/screenshots/terminal.png</image>
|
||||
<caption>SSH Terminal Interface</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
|
||||
<url type="homepage">https://github.com/Termix-SSH/Termix</url>
|
||||
<url type="bugtracker">https://github.com/Termix-SSH/Support/issues</url>
|
||||
<url type="help">https://docs.termix.site</url>
|
||||
<url type="vcs-browser">https://github.com/Termix-SSH/Termix</url>
|
||||
|
||||
<content_rating type="oars-1.1">
|
||||
<content_attribute id="social-info">moderate</content_attribute>
|
||||
</content_rating>
|
||||
|
||||
<releases>
|
||||
<release version="VERSION_PLACEHOLDER" date="DATE_PLACEHOLDER">
|
||||
<description>
|
||||
<p>Latest release of Termix</p>
|
||||
</description>
|
||||
<url>https://github.com/Termix-SSH/Termix/releases</url>
|
||||
</release>
|
||||
</releases>
|
||||
|
||||
<categories>
|
||||
<category>Development</category>
|
||||
<category>Network</category>
|
||||
<category>System</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>ssh</keyword>
|
||||
<keyword>terminal</keyword>
|
||||
<keyword>server</keyword>
|
||||
<keyword>management</keyword>
|
||||
<keyword>tunnel</keyword>
|
||||
<keyword>file-manager</keyword>
|
||||
</keywords>
|
||||
|
||||
<provides>
|
||||
<binary>termix</binary>
|
||||
</provides>
|
||||
|
||||
<requires>
|
||||
<internet>always</internet>
|
||||
</requires>
|
||||
</component>
|
||||
@@ -1,69 +0,0 @@
|
||||
app-id: com.karmaa.termix
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "23.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
base-version: "23.08"
|
||||
command: termix
|
||||
separate-locales: false
|
||||
|
||||
finish-args:
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --socket=pulseaudio
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --socket=ssh-auth
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
|
||||
modules:
|
||||
- name: termix
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- chmod +x termix.AppImage
|
||||
- ./termix.AppImage --appimage-extract
|
||||
|
||||
- install -Dm755 squashfs-root/termix /app/bin/termix
|
||||
- cp -r squashfs-root/resources /app/bin/
|
||||
- cp -r squashfs-root/locales /app/bin/ || true
|
||||
|
||||
- install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop
|
||||
|
||||
- install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml
|
||||
|
||||
- install -Dm644 com.karmaa.termix.svg /app/share/icons/hicolor/scalable/apps/com.karmaa.termix.svg
|
||||
- install -Dm644 icon-256.png /app/share/icons/hicolor/256x256/apps/com.karmaa.termix.png || true
|
||||
- install -Dm644 icon-128.png /app/share/icons/hicolor/128x128/apps/com.karmaa.termix.png || true
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
sha256: CHECKSUM_X64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
- x86_64
|
||||
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
sha256: CHECKSUM_ARM64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
- aarch64
|
||||
|
||||
- type: file
|
||||
path: com.karmaa.termix.desktop
|
||||
|
||||
- type: file
|
||||
path: com.karmaa.termix.metainfo.xml
|
||||
|
||||
- type: file
|
||||
path: com.karmaa.termix.svg
|
||||
|
||||
- type: file
|
||||
path: icon-256.png
|
||||
|
||||
- type: file
|
||||
path: icon-128.png
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"only-arches": ["x86_64", "aarch64"],
|
||||
"skip-icons-check": false,
|
||||
"skip-appstream-check": false
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION="$1"
|
||||
CHECKSUM="$2"
|
||||
RELEASE_DATE="$3"
|
||||
|
||||
if [ -z "$VERSION" ] || [ -z "$CHECKSUM" ] || [ -z "$RELEASE_DATE" ]; then
|
||||
echo "Usage: $0 <version> <checksum> <release-date>"
|
||||
echo "Example: $0 1.8.0 abc123... 2025-10-26"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Preparing Flatpak submission for version $VERSION"
|
||||
|
||||
cp public/icon.svg flatpak/com.karmaa.termix.svg
|
||||
echo "✓ Copied SVG icon"
|
||||
|
||||
if command -v convert &> /dev/null; then
|
||||
convert public/icon.png -resize 256x256 flatpak/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak/icon-128.png
|
||||
echo "✓ Generated PNG icons"
|
||||
else
|
||||
cp public/icon.png flatpak/icon-256.png
|
||||
cp public/icon.png flatpak/icon-128.png
|
||||
echo "⚠ ImageMagick not found, using original icon"
|
||||
fi
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" flatpak/com.karmaa.termix.yml
|
||||
echo "✓ Updated manifest with version $VERSION"
|
||||
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak/com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak/com.karmaa.termix.metainfo.xml
|
||||
@@ -1,24 +0,0 @@
|
||||
cask "termix" do
|
||||
version "VERSION_PLACEHOLDER"
|
||||
sha256 "CHECKSUM_PLACEHOLDER"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg"
|
||||
name "Termix"
|
||||
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
|
||||
homepage "https://github.com/Termix-SSH/Termix"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
app "Termix.app"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/termix",
|
||||
"~/Library/Caches/com.karmaa.termix",
|
||||
"~/Library/Caches/com.karmaa.termix.ShipIt",
|
||||
"~/Library/Preferences/com.karmaa.termix.plist",
|
||||
"~/Library/Saved Application State/com.karmaa.termix.savedState",
|
||||
]
|
||||
end
|
||||
@@ -5,39 +5,9 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Termix</title>
|
||||
<style>
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skinny-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4a4a4a #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e1e21;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #1e1e21;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,152 +1,61 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"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",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && 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",
|
||||
"build:mac": "npm run build && electron-builder --mac --universal"
|
||||
"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-slider": "^1.3.6",
|
||||
"@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.14",
|
||||
"@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.15",
|
||||
"@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",
|
||||
"cmdk": "^1.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",
|
||||
"recharts": "^3.2.1",
|
||||
"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",
|
||||
"mitt": "^3.0.1",
|
||||
"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",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"wait-on": "^9.0.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
"tailwindcss": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@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",
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.3",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 483 B |
|
Before Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 986 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 685 KiB |
|
Before Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 723 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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 "$@"
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,818 @@
|
||||
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 AuthModal from "./modals/AuthModal.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 ProfileModal from "./modals/ProfileModal.jsx";
|
||||
import ErrorModal from "./modals/ErrorModal.jsx";
|
||||
import EditHostModal from "./modals/EditHostModal.jsx";
|
||||
import NoAuthenticationModal from "./modals/NoAuthenticationModal.jsx";
|
||||
import eventBus from "./other/eventBus.jsx";
|
||||
|
||||
function App() {
|
||||
const [isAddHostHidden, setIsAddHostHidden] = useState(true);
|
||||
const [isAuthModalHidden, setIsAuthModalHidden] = 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: "",
|
||||
sshKey: "",
|
||||
port: 22,
|
||||
authMethod: "Select Auth",
|
||||
rememberHost: true,
|
||||
storePassword: true,
|
||||
connectionType: "ssh",
|
||||
rdpDomain: "",
|
||||
rdpWindowsAuthentication: true,
|
||||
rdpConsole: false,
|
||||
vncScaling: "100%",
|
||||
vncQuality: "High"
|
||||
});
|
||||
const [editHostForm, setEditHostForm] = useState({
|
||||
name: "",
|
||||
folder: "",
|
||||
ip: "",
|
||||
user: "",
|
||||
password: "",
|
||||
sshKey: "",
|
||||
port: 22,
|
||||
authMethod: "Select Auth",
|
||||
rememberHost: true,
|
||||
storePassword: true,
|
||||
});
|
||||
const [isNoAuthHidden, setIsNoAuthHidden] = useState(true);
|
||||
const [authForm, setAuthForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
const [noAuthenticationForm, setNoAuthenticationForm] = useState({
|
||||
authMethod: 'Select Auth',
|
||||
password: '',
|
||||
sshKey: '',
|
||||
keyType: '',
|
||||
})
|
||||
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);
|
||||
const [isHostViewerMenuOpen, setIsHostViewerMenuOpen] = useState(null);
|
||||
|
||||
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);
|
||||
setIsAuthModalHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionToken) {
|
||||
setIsLoggingIn(false);
|
||||
setIsAuthModalHidden(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');
|
||||
setIsAuthModalHidden(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');
|
||||
setIsAuthModalHidden(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);
|
||||
setIsAuthModalHidden(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);
|
||||
setIsAuthModalHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
}
|
||||
isLoginInProgress = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
loginAttempts++;
|
||||
};
|
||||
|
||||
attemptLoginInterval = setInterval(attemptLogin, 100);
|
||||
attemptLogin();
|
||||
|
||||
return () => {
|
||||
isComponentMounted = false;
|
||||
clearTimeout(loginTimeout);
|
||||
clearInterval(attemptLoginInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAddHost = () => {
|
||||
if (addHostForm.ip && addHostForm.port) {
|
||||
if (addHostForm.connectionType === 'ssh' && !addHostForm.user) {
|
||||
setErrorMessage("Please fill out all required fields (IP, User, Port).");
|
||||
setIsErrorHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addHostForm.rememberHost) {
|
||||
connectToHost();
|
||||
setIsAddHostHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addHostForm.connectionType === 'ssh') {
|
||||
if (addHostForm.authMethod === 'Select Auth') {
|
||||
setErrorMessage("Please select an authentication method.");
|
||||
setIsErrorHidden(false);
|
||||
return;
|
||||
}
|
||||
if (addHostForm.authMethod === 'password' && !addHostForm.password) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
if (addHostForm.authMethod === 'sshKey' && !addHostForm.sshKey) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!addHostForm.password) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connectToHost();
|
||||
if (!addHostForm.storePassword) {
|
||||
addHostForm.password = '';
|
||||
}
|
||||
handleSaveHost();
|
||||
setIsAddHostHidden(true);
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || "Failed to add host");
|
||||
setIsErrorHidden(false);
|
||||
}
|
||||
} else {
|
||||
setErrorMessage("Please fill out all required fields.");
|
||||
setIsErrorHidden(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
sshKey: addHostForm.rememberHost && addHostForm.authMethod === 'sshKey' ? addHostForm.sshKey : 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: "", sshKey: "", port: 22, authMethod: "Select Auth", rememberHost: true, storePassword: true, connectionType: "ssh", rdpDomain: "", rdpWindowsAuthentication: true, rdpConsole: false, vncScaling: "100%", vncQuality: "High" });
|
||||
}
|
||||
|
||||
const handleAuthSubmit = (form) => {
|
||||
try {
|
||||
setIsNoAuthHidden(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const updatedTerminals = terminals.map((terminal) => {
|
||||
if (terminal.id === activeTab) {
|
||||
return {
|
||||
...terminal,
|
||||
hostConfig: {
|
||||
...terminal.hostConfig,
|
||||
password: form.authMethod === 'password' ? form.password : undefined,
|
||||
sshKey: form.authMethod === 'sshKey' ? form.sshKey : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
return terminal;
|
||||
});
|
||||
|
||||
setTerminals(updatedTerminals);
|
||||
|
||||
setNoAuthenticationForm({
|
||||
authMethod: 'Select Auth',
|
||||
password: '',
|
||||
sshKey: '',
|
||||
keyType: '',
|
||||
});
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error("Authentication error:", error);
|
||||
setErrorMessage("Failed to authenticate: " + (error.message || "Unknown error"));
|
||||
setIsErrorHidden(false);
|
||||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
sshKey: hostConfig.sshKey?.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 = async () => {
|
||||
try {
|
||||
let hostConfig = {
|
||||
name: addHostForm.name || addHostForm.ip,
|
||||
folder: addHostForm.folder,
|
||||
ip: addHostForm.ip,
|
||||
user: addHostForm.user,
|
||||
password: (addHostForm.authMethod === 'password' || addHostForm.connectionType === 'vnc' || addHostForm.connectionType === 'rdp') ? addHostForm.password : undefined,
|
||||
sshKey: addHostForm.connectionType === 'ssh' && addHostForm.authMethod === 'sshKey' ? addHostForm.sshKey : undefined,
|
||||
port: String(addHostForm.port),
|
||||
connectionType: addHostForm.connectionType,
|
||||
rdpDomain: addHostForm.connectionType === 'rdp' ? addHostForm.rdpDomain : undefined,
|
||||
rdpWindowsAuthentication: addHostForm.connectionType === 'rdp' ? addHostForm.rdpWindowsAuthentication : undefined,
|
||||
rdpConsole: addHostForm.connectionType === 'rdp' ? addHostForm.rdpConsole : undefined,
|
||||
vncScaling: addHostForm.connectionType === 'vnc' ? addHostForm.vncScaling : undefined,
|
||||
vncQuality: addHostForm.connectionType === 'vnc' ? addHostForm.vncQuality : undefined
|
||||
}
|
||||
if (userRef.current) {
|
||||
await userRef.current.saveHost({
|
||||
hostConfig,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginUser = ({ username, password, sessionToken, onSuccess, onFailure }) => {
|
||||
if (userRef.current) {
|
||||
if (sessionToken) {
|
||||
userRef.current.loginUser({
|
||||
sessionToken,
|
||||
onSuccess: () => {
|
||||
setIsAuthModalHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onFailure: (error) => {
|
||||
localStorage.removeItem('sessionToken');
|
||||
setIsAuthModalHidden(false);
|
||||
setIsLoggingIn(false);
|
||||
if (onFailure) onFailure(error);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
userRef.current.loginUser({
|
||||
username,
|
||||
password,
|
||||
onSuccess: () => {
|
||||
setIsAuthModalHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
if (onSuccess) onSuccess();
|
||||
},
|
||||
onFailure: (error) => {
|
||||
setIsAuthModalHidden(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));
|
||||
setIsEditHostHidden(true);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateEditHostForm(oldConfig);
|
||||
} catch (error) {
|
||||
console.error('Edit failed:', error);
|
||||
setErrorMessage(`Edit failed: ${error.message || 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) : setIsAuthModalHidden(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}
|
||||
setErrorMessage={setErrorMessage}
|
||||
setIsErrorHidden={setIsErrorHidden}
|
||||
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={noAuthenticationForm}
|
||||
setForm={setNoAuthenticationForm}
|
||||
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}
|
||||
isHostViewerMenuOpen={isHostViewerMenuOpen}
|
||||
setIsHostViewerMenuOpen={setIsHostViewerMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorModal
|
||||
isHidden={isErrorHidden}
|
||||
errorMessage={errorMessage}
|
||||
setIsErrorHidden={setIsErrorHidden}
|
||||
/>
|
||||
|
||||
<AuthModal
|
||||
isHidden={isAuthModalHidden}
|
||||
form={authForm}
|
||||
setForm={setAuthForm}
|
||||
handleLoginUser={handleLoginUser}
|
||||
handleCreateUser={handleCreateUser}
|
||||
handleGuestLogin={handleGuestLogin}
|
||||
setIsAuthModalHidden={setIsAuthModalHidden}
|
||||
/>
|
||||
|
||||
{/* User component */}
|
||||
<User
|
||||
ref={userRef}
|
||||
onLoginSuccess={() => {
|
||||
setIsAuthModalHidden(true);
|
||||
setIsLoggingIn(false);
|
||||
setIsErrorHidden(true);
|
||||
}}
|
||||
onCreateSuccess={() => {
|
||||
setIsAuthModalHidden(true);
|
||||
handleLoginUser({
|
||||
username: authForm.username,
|
||||
password: authForm.password,
|
||||
onSuccess: () => {
|
||||
setIsAuthModalHidden(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);
|
||||
eventBus.emit('failedLoginUser');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CssVarsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,223 @@
|
||||
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,
|
||||
isHostViewerMenuOpen,
|
||||
setIsHostViewerMenuOpen,
|
||||
}) {
|
||||
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 &&
|
||||
!isHostViewerMenuOpen &&
|
||||
!isAnyModalOpen
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [onClose, isAddHostHidden, isEditHostHidden, isErrorHidden, isHostViewerMenuOpen, 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}
|
||||
isMenuOpen={isHostViewerMenuOpen || false}
|
||||
setIsMenuOpen={setIsHostViewerMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</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,
|
||||
isHostViewerMenuOpen: PropTypes.bool,
|
||||
setIsHostViewerMenuOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Launchpad;
|
||||
@@ -0,0 +1,477 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button, Input, Menu, MenuItem, IconButton } from "@mui/joy";
|
||||
import ShareHostModal from "../../modals/ShareHostModal";
|
||||
|
||||
function HostViewer({
|
||||
getHosts,
|
||||
connectToHost,
|
||||
setIsAddHostHidden,
|
||||
deleteHost,
|
||||
editHost,
|
||||
openEditPanel,
|
||||
shareHost,
|
||||
onModalOpen,
|
||||
onModalClose,
|
||||
userRef,
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
}) {
|
||||
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 [selectedHost, setSelectedHost] = useState(null);
|
||||
const anchorEl = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target) && anchorEl.current && !anchorEl.current.contains(event.target)) {
|
||||
setIsMenuOpen(false);
|
||||
setSelectedHost(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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
|
||||
variant="outlined"
|
||||
className="text-white"
|
||||
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",
|
||||
borderColor: "#3d3d3d",
|
||||
borderWidth: "2px",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<IconButton
|
||||
variant="outlined"
|
||||
className="text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedHost(hostWrapper);
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
anchorEl.current = e.currentTarget;
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
sx={{
|
||||
backgroundColor: "#6e6e6e",
|
||||
"&:hover": { backgroundColor: "#0f0f0f" },
|
||||
opacity: isDeleting ? 0.5 : 1,
|
||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||
borderColor: "#3d3d3d",
|
||||
borderWidth: "2px",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
⋮
|
||||
</IconButton>
|
||||
</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 (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{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}
|
||||
/>
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
anchorEl={anchorEl.current}
|
||||
open={isMenuOpen}
|
||||
onClose={() => {
|
||||
setIsMenuOpen(false);
|
||||
setSelectedHost(null);
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiMenu-list": {
|
||||
backgroundColor: "#6e6e6e",
|
||||
color: "white"
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedHost && (
|
||||
selectedHost.createdBy?._id === userRef.current?.getUser()?.id ? (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedHostForShare(selectedHost);
|
||||
setIsShareModalHidden(false);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Share
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditPanel(selectedHost.config);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(e, selectedHost);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(e, selectedHost);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Removing..." : "Remove Share"}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Menu>
|
||||
</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,
|
||||
isMenuOpen: PropTypes.bool.isRequired,
|
||||
setIsMenuOpen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default HostViewer;
|
||||
@@ -0,0 +1,283 @@
|
||||
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, setErrorMessage, setIsErrorHidden }, 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 - 8;
|
||||
const parentHeight = parentContainer.clientHeight - 12;
|
||||
|
||||
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,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
padding: 2,
|
||||
});
|
||||
|
||||
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"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
timeout: 20000,
|
||||
}
|
||||
);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
terminalInstance.current.write(`\r\n*** Socket connection error: ${error.message} ***\r\n`);
|
||||
console.error("Socket connection error:", error);
|
||||
});
|
||||
|
||||
socket.on("connect_timeout", () => {
|
||||
terminalInstance.current.write(`\r\n*** Socket connection timeout ***\r\n`);
|
||||
console.error("Socket connection timeout");
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
console.error("SSH connection error:", err);
|
||||
const isAuthError = err.toLowerCase().includes("authentication") || err.toLowerCase().includes("auth");
|
||||
if (isAuthError && !hostConfig.password?.trim() && !hostConfig.sshKey?.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.sshKey?.trim()) {
|
||||
setIsNoAuthHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sshConfig = {
|
||||
ip: hostConfig.ip,
|
||||
user: hostConfig.user,
|
||||
port: Number(hostConfig.port) || 22,
|
||||
password: hostConfig.password?.trim(),
|
||||
sshKey: hostConfig.sshKey?.trim(),
|
||||
rsaKey: hostConfig.sshKey?.trim() || hostConfig.rsaKey?.trim(),
|
||||
};
|
||||
|
||||
socket.emit("connectToHost", cols, rows, sshConfig);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (terminalInstance.current) {
|
||||
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;
|
||||
|
||||
if (terminalInstance.current) {
|
||||
terminalInstance.current.onData((data) => {
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
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 => {
|
||||
if (text && socketRef.current?.connected) {
|
||||
const processedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r");
|
||||
socketRef.current.emit("data", processedText);
|
||||
}
|
||||
}).catch(() => {
|
||||
setErrorMessage("Paste failed: Clipboard access denied. Instead, use Control Shift V.");
|
||||
setIsErrorHidden(false);
|
||||
}).finally(() => {
|
||||
setTimeout(() => {
|
||||
isPasting = false;
|
||||
}, 300);
|
||||
});
|
||||
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.sshKey?.trim() && !authModalShown) {
|
||||
authModalShown = true;
|
||||
setIsNoAuthHidden(false);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
if (terminalInstance.current) {
|
||||
terminalInstance.current.write(`\r\n*** Socket disconnected: ${reason} ***\r\n`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("reconnect", (attemptNumber) => {
|
||||
if (terminalInstance.current) {
|
||||
terminalInstance.current.write(`\r\n*** Socket reconnected after ${attemptNumber} attempts ***\r\n`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("reconnect_error", (error) => {
|
||||
console.error("Socket reconnect error:", error);
|
||||
if (terminalInstance.current) {
|
||||
terminalInstance.current.write(`\r\n*** Socket reconnect error: ${error.message} ***\r\n`);
|
||||
}
|
||||
});
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
if (socketRef.current && socketRef.current.connected) {
|
||||
socketRef.current.emit("ping");
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
socketRef.current.on("pong", () => {});
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
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 resizeObserver = new ResizeObserver(() => {
|
||||
resizeTerminal();
|
||||
});
|
||||
|
||||
resizeObserver.observe(parentContainer);
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeTerminal();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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(2px) translateX(3px)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
NewTerminal.displayName = "NewTerminal";
|
||||
|
||||
NewTerminal.propTypes = {
|
||||
hostConfig: PropTypes.shape({
|
||||
ip: PropTypes.string.isRequired,
|
||||
user: PropTypes.string.isRequired,
|
||||
password: PropTypes.string,
|
||||
sshKey: PropTypes.string,
|
||||
rsaKey: PropTypes.string,
|
||||
port: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
}).isRequired,
|
||||
isVisible: PropTypes.bool.isRequired,
|
||||
setIsNoAuthHidden: PropTypes.func.isRequired,
|
||||
setErrorMessage: PropTypes.func.isRequired,
|
||||
setIsErrorHidden: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
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 existingHosts = await getAllHosts();
|
||||
|
||||
const duplicateNameHost = existingHosts.find(host =>
|
||||
host.config.name &&
|
||||
host.config.name.toLowerCase() === hostConfig.hostConfig.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (duplicateNameHost) {
|
||||
return onFailure("A host with this name already exists. Please choose a different name.");
|
||||
}
|
||||
|
||||
if (!hostConfig.hostConfig.name) {
|
||||
const duplicateIpHost = existingHosts.find(host =>
|
||||
host.config.ip.toLowerCase() === hostConfig.hostConfig.ip.toLowerCase()
|
||||
);
|
||||
|
||||
if (duplicateIpHost) {
|
||||
return onFailure("A host with this IP already exists. Please provide a unique name.");
|
||||
}
|
||||
}
|
||||
|
||||
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 || '',
|
||||
sshKey: host.config.sshKey || '',
|
||||
} : {}
|
||||
})).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 {
|
||||
const existingHosts = await getAllHosts();
|
||||
|
||||
const duplicateNameHost = existingHosts.find(host =>
|
||||
host.config.name &&
|
||||
host.config.name.toLowerCase() === newHostConfig.name.toLowerCase() &&
|
||||
host.config.ip.toLowerCase() !== oldHostConfig.ip.toLowerCase()
|
||||
);
|
||||
|
||||
if (duplicateNameHost) {
|
||||
return onFailure("A host with this name already exists. Please choose a different name.");
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -1,245 +0,0 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import { recentActivity, sshData } from "./database/db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../types/index.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||
const now = Date.now();
|
||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||
|
||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
|
||||
activityRateLimiter.set(rateLimitKey, now);
|
||||
|
||||
if (activityRateLimiter.size > 10000) {
|
||||
const entriesToDelete: string[] = [];
|
||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||
if (now - timestamp > RATE_LIMIT_MS * 2) {
|
||||
entriesToDelete.push(key);
|
||||
}
|
||||
}
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (const activity of toDelete) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
eq(recentActivity.userId, userId),
|
||||
);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
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,
|
||||
sshKey: hostConfig.sshKey?.trim() || undefined,
|
||||
};
|
||||
|
||||
const finalName = cleanConfig.name || cleanConfig.ip;
|
||||
|
||||
// Check for hosts with the same name (case insensitive)
|
||||
const existingHostByName = await Host.findOne({
|
||||
createdBy: userId,
|
||||
name: { $regex: new RegExp('^' + finalName + '$', 'i') }
|
||||
});
|
||||
|
||||
if (existingHostByName) {
|
||||
logger.warn(`Host with name ${finalName} already exists for user: ${userId}`);
|
||||
return callback({ error: `Host with name "${finalName}" already exists. Please choose a different name.` });
|
||||
}
|
||||
|
||||
// Prevent duplicate IPs if using IP as name
|
||||
if (!cleanConfig.name) {
|
||||
const existingHostByIp = await Host.findOne({
|
||||
createdBy: userId,
|
||||
config: { $regex: new RegExp(cleanConfig.ip, 'i') }
|
||||
});
|
||||
|
||||
if (existingHostByIp) {
|
||||
const decryptedConfig = decryptData(existingHostByIp.config, userId, sessionToken);
|
||||
if (decryptedConfig && decryptedConfig.ip.toLowerCase() === cleanConfig.ip.toLowerCase()) {
|
||||
logger.warn(`Host with IP ${cleanConfig.ip} already exists for user: ${userId}`);
|
||||
return callback({ error: `Host with IP "${cleanConfig.ip}" already exists. Please provide a unique name.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
// Find the host to be edited
|
||||
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 finalName = newHostConfig.name?.trim() || newHostConfig.ip.trim();
|
||||
|
||||
// If the name is being changed, check for duplicates using case-insensitive comparison
|
||||
if (finalName.toLowerCase() !== host.name.toLowerCase()) {
|
||||
// Check for duplicate name using regex for case-insensitive comparison
|
||||
const duplicateNameHost = await Host.findOne({
|
||||
createdBy: userId,
|
||||
_id: { $ne: host._id }, // Exclude the current host
|
||||
name: { $regex: new RegExp('^' + finalName + '$', 'i') }
|
||||
});
|
||||
|
||||
if (duplicateNameHost) {
|
||||
logger.warn(`Host with name ${finalName} already exists for user: ${userId}`);
|
||||
return callback({ error: `Host with name "${finalName}" already exists. Please choose a different name.` });
|
||||
}
|
||||
}
|
||||
|
||||
// If IP is changed and no custom name provided, check for duplicate IP
|
||||
if (newHostConfig.ip !== oldHostConfig.ip && !newHostConfig.name) {
|
||||
const duplicateIpHost = hosts.find(h => {
|
||||
if (h._id.toString() === host._id.toString()) return false;
|
||||
const decryptedConfig = decryptData(h.config, userId, sessionToken);
|
||||
return decryptedConfig && decryptedConfig.ip.toLowerCase() === newHostConfig.ip.toLowerCase();
|
||||
});
|
||||
|
||||
if (duplicateIpHost) {
|
||||
logger.warn(`Host with IP ${newHostConfig.ip} already exists for user: ${userId}`);
|
||||
return callback({ error: `Host with IP "${newHostConfig.ip}" already exists. Please provide a unique name.` });
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
sshKey: newHostConfig.sshKey?.trim() || undefined,
|
||||
};
|
||||
|
||||
const encryptedConfig = encryptData(cleanConfig, userId, sessionToken);
|
||||
if (!encryptedConfig) {
|
||||
logger.error('Encryption failed for host config');
|
||||
return callback({ error: 'Configuration encryption failed' });
|
||||
}
|
||||
|
||||
host.name = finalName;
|
||||
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: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -1,736 +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)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
|
||||
const actualDbPath = ":memory:";
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
}
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
|
||||
if (migrationStatus.needsMigration) {
|
||||
const migrationResult = await migration.migrateDatabase();
|
||||
|
||||
if (migrationResult.success) {
|
||||
migration.cleanupOldBackups();
|
||||
|
||||
if (
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
|
||||
) {
|
||||
const decryptedBuffer =
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(
|
||||
encryptedDbPath,
|
||||
);
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
isNewDatabase = false;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Migration completed but encrypted database file not found",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
databaseLogger.error("Automatic database migration failed", null, {
|
||||
operation: "auto_migration_failed",
|
||||
error: migrationResult.error,
|
||||
migratedTables: migrationResult.migratedTables,
|
||||
migratedRows: migrationResult.migratedRows,
|
||||
duration: migrationResult.duration,
|
||||
backupPath: migrationResult.backupPath,
|
||||
});
|
||||
throw new Error(
|
||||
`Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize memory database", error, {
|
||||
operation: "db_memory_init_failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
encryptedDbExists:
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
databaseKeyAvailable: !!process.env.DATABASE_KEY,
|
||||
databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const diagnosticInfo =
|
||||
DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
|
||||
databaseLogger.error(
|
||||
"Database encryption diagnostic completed - check logs above for details",
|
||||
null,
|
||||
{
|
||||
operation: "db_encryption_diagnostic_completed",
|
||||
filesConsistent: diagnosticInfo.validation.filesConsistent,
|
||||
sizeMismatch: diagnosticInfo.validation.sizeMismatch,
|
||||
},
|
||||
);
|
||||
} catch (diagError) {
|
||||
databaseLogger.warn("Failed to generate diagnostic information", {
|
||||
operation: "db_diagnostic_failed",
|
||||
error:
|
||||
diagError instanceof Error ? diagError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
memoryDatabase = new Database(":memory:");
|
||||
isNewDatabase = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCompleteDatabase(): Promise<void> {
|
||||
await initializeDatabaseAsync();
|
||||
|
||||
databaseLogger.info(`Initializing SQLite database`, {
|
||||
operation: "db_init",
|
||||
path: actualDbPath,
|
||||
encrypted:
|
||||
enableFileEncryption &&
|
||||
DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
|
||||
inMemory: true,
|
||||
isNewDatabase,
|
||||
});
|
||||
|
||||
sqlite = memoryDatabase;
|
||||
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
|
||||
db = drizzle(sqlite, { schema });
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_oidc INTEGER NOT NULL DEFAULT 0,
|
||||
oidc_identifier TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
issuer_url TEXT,
|
||||
authorization_url TEXT,
|
||||
token_url TEXT,
|
||||
identifier_path TEXT,
|
||||
name_path TEXT,
|
||||
scopes TEXT DEFAULT 'openid email profile',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_backup_codes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
terminal_config TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_pinned (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
alert_id TEXT NOT NULL,
|
||||
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT 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) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
credential_id INTEGER NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS command_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize default settings", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT "${column}"
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN "${column}" ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
|
||||
addColumnIfNotExists("users", "client_id", "TEXT");
|
||||
addColumnIfNotExists("users", "client_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "issuer_url", "TEXT");
|
||||
addColumnIfNotExists("users", "authorization_url", "TEXT");
|
||||
addColumnIfNotExists("users", "token_url", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "identifier_path", "TEXT");
|
||||
addColumnIfNotExists("users", "name_path", "TEXT");
|
||||
addColumnIfNotExists("users", "scopes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "totp_secret", "TEXT");
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"auth_type",
|
||||
'TEXT NOT NULL DEFAULT "password"',
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"created_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
"INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_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");
|
||||
|
||||
addColumnIfNotExists("snippets", "folder", "TEXT");
|
||||
addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM snippet_folders LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snippet_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create snippet_folders table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sessions table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,278 +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 sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastActiveAt: text("last_active_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
username: text("username").notNull(),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
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, { onDelete: "set null" }),
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
tunnelConnections: text("tunnel_connections"),
|
||||
jumpHosts: text("jump_hosts"),
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
alertId: text("alert_id").notNull(),
|
||||
dismissedAt: text("dismissed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").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, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
usedAt: text("used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippets = sqliteTable("snippets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
content: text("content").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
order: integer("order").notNull().default(0),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
hostName: text("host_name"),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const commandHistory = sqliteTable("command_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||