Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c67d914e61 | |||
| e3cb1f82af | |||
| 9b1cc3dbf4 | |||
| e87abe676e | |||
| 4f092f7c4b | |||
| 579fa81ac3 | |||
| 1492549d36 | |||
| 8aa8911671 | |||
| 76aad24f94 | |||
| 2931fd71bb | |||
| 0656e1aee4 | |||
| 4aa7d6e3d1 | |||
| dfa8f25299 | |||
| 69eca2652b | |||
| aef1036ab0 | |||
| d695663e41 | |||
| 288e73cc20 | |||
| d7450d5867 | |||
| e9e30cd318 | |||
| a255a08903 | |||
| ea2e59abd8 | |||
| 5caadf1d5d | |||
| 98d3c86cc7 | |||
| ee9824d47f | |||
| effe419d97 | |||
| bb5d104b52 | |||
| e4361b9bd1 | |||
| 1b8f6b54b4 | |||
| a0237dc155 | |||
| 55789db3c5 | |||
| dcf5468acc | |||
| 0ff03110c9 | |||
| 44049b8031 | |||
| ffec17201d | |||
| 59088606b5 | |||
| 921a762198 | |||
| 094f34b671 | |||
| 726156a734 | |||
| 749be1fc6e | |||
| 98b760ce64 | |||
| 1c9d4560c7 | |||
| b388241d09 | |||
| 644f23d742 | |||
| c4dba59979 | |||
| 536dc35258 | |||
| 366667ca92 | |||
| cde621275b | |||
| d1b95d698f | |||
| f85609660a | |||
| f48e645a56 | |||
| da31164e23 | |||
| f17a0c2854 | |||
| 9936ef469d | |||
| fc87146e4b | |||
| fceb430d22 | |||
| 5168ded79d | |||
| 51e6826c95 | |||
| ad86c2040b | |||
| 7139290d14 | |||
| f0647dc7c1 | |||
| 403800f42b | |||
| 84ca8080f0 |
@@ -31,7 +31,6 @@ Thumbs.db
|
||||
.gitignore
|
||||
|
||||
README.md
|
||||
README-CN.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
github: [LukeGus]
|
||||
@@ -29,3 +29,4 @@ _(Optional: add before/after screenshots, GIFs, or console output)_
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Supports mobile and desktop UI/app (if applicable)
|
||||
- [ ] I have read [Contributing.md](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)
|
||||
- [ ] This is not a translation request. See [docs](https://docs.termix.site/translations)
|
||||
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
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 == ''
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -72,10 +72,6 @@ jobs:
|
||||
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'
|
||||
@@ -136,7 +132,7 @@ jobs:
|
||||
|
||||
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 == ''
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -199,17 +195,6 @@ jobs:
|
||||
|
||||
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'
|
||||
@@ -282,9 +267,94 @@ jobs:
|
||||
path: release/termix_linux_armv7l_portable.tar.gz
|
||||
retention-days: 30
|
||||
|
||||
- name: Install Flatpak builder and dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder imagemagick
|
||||
|
||||
- name: Add Flathub repository
|
||||
run: |
|
||||
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Install Flatpak runtime and SDK
|
||||
run: |
|
||||
sudo flatpak install -y flathub org.freedesktop.Platform//24.08
|
||||
sudo flatpak install -y flathub org.freedesktop.Sdk//24.08
|
||||
sudo flatpak install -y flathub org.electronjs.Electron2.BaseApp//24.08
|
||||
|
||||
- name: Get version for Flatpak
|
||||
id: flatpak-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare Flatpak files
|
||||
run: |
|
||||
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
||||
RELEASE_DATE="${{ steps.flatpak-version.outputs.release_date }}"
|
||||
|
||||
CHECKSUM_X64=$(sha256sum "release/termix_linux_x64_appimage.AppImage" | awk '{print $1}')
|
||||
CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}')
|
||||
|
||||
mkdir -p flatpak-build
|
||||
cp flatpak/com.karmaa.termix.yml flatpak-build/
|
||||
cp flatpak/com.karmaa.termix.desktop flatpak-build/
|
||||
cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
|
||||
cp public/icon.svg flatpak-build/com.karmaa.termix.svg
|
||||
convert public/icon.png -resize 256x256 flatpak-build/icon-256.png
|
||||
convert public/icon.png -resize 128x128 flatpak-build/icon-128.png
|
||||
|
||||
cd flatpak-build
|
||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||
sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
|
||||
sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
|
||||
sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: Build Flatpak bundle
|
||||
run: |
|
||||
cd flatpak-build
|
||||
flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml
|
||||
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
FLATPAK_ARCH="x86_64"
|
||||
elif [ "$ARCH" = "aarch64" ]; then
|
||||
FLATPAK_ARCH="aarch64"
|
||||
else
|
||||
FLATPAK_ARCH="$ARCH"
|
||||
fi
|
||||
|
||||
flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
- name: Create flatpakref file
|
||||
run: |
|
||||
VERSION="${{ steps.flatpak-version.outputs.version }}"
|
||||
cp flatpak/com.karmaa.termix.flatpakref release/
|
||||
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpak
|
||||
path: release/termix_linux_flatpak.flatpak
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Flatpakref
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpakref
|
||||
path: release/com.karmaa.termix.flatpakref
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
|
||||
if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -361,6 +431,7 @@ jobs:
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
@@ -418,6 +489,7 @@ jobs:
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
|
||||
npm run build
|
||||
@@ -425,11 +497,6 @@ jobs:
|
||||
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
|
||||
@@ -463,42 +530,51 @@ jobs:
|
||||
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
|
||||
- name: Get version for Homebrew
|
||||
id: homebrew-version
|
||||
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
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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
|
||||
- name: Generate Homebrew Cask
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release')
|
||||
run: |
|
||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||
DMG_PATH="release/termix_macos_universal_dmg.dmg"
|
||||
|
||||
CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')
|
||||
|
||||
mkdir -p homebrew-generated
|
||||
cp Casks/termix.rb homebrew-generated/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
|
||||
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
|
||||
|
||||
- name: Upload Homebrew Cask as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file'
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: false
|
||||
name: termix_macos_homebrew_cask
|
||||
path: homebrew-generated/termix.rb
|
||||
retention-days: 30
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true' && github.event.inputs.artifact_destination == 'submit'
|
||||
- name: Upload Homebrew Cask to release
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||
RELEASE_TAG="release-$VERSION-tag"
|
||||
|
||||
- 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
|
||||
gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
|
||||
echo "Release $RELEASE_TAG not found"
|
||||
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
|
||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
@@ -508,8 +584,7 @@ jobs:
|
||||
|
||||
submit-to-chocolatey:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-windows]
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '')
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -525,20 +600,25 @@ jobs:
|
||||
$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
|
||||
- name: Download and prepare MSI info from public release
|
||||
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
|
||||
$MSI_NAME = "termix_windows_x64_msi.msi"
|
||||
$DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)"
|
||||
|
||||
Write-Host "Downloading from $DOWNLOAD_URL"
|
||||
New-Item -ItemType Directory -Force -Path "release_asset"
|
||||
$DOWNLOAD_PATH = "release_asset\$MSI_NAME"
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $DOWNLOAD_PATH -UseBasicParsing
|
||||
} catch {
|
||||
Write-Error "Failed to download MSI from $DOWNLOAD_URL. Please ensure the release and asset exist."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$CHECKSUM = (Get-FileHash -Path $DOWNLOAD_PATH -Algorithm SHA256).Hash
|
||||
echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT
|
||||
|
||||
@@ -608,9 +688,9 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
submit-to-flatpak:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-linux]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -628,30 +708,27 @@ jobs:
|
||||
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
|
||||
- name: Download and prepare AppImage info from public release
|
||||
id: appimage-info
|
||||
run: |
|
||||
VERSION="${{ steps.package-version.outputs.version }}"
|
||||
mkdir -p release_assets
|
||||
|
||||
APPIMAGE_X64_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_X64_NAME="termix_linux_x64_appimage.AppImage"
|
||||
URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
|
||||
PATH_X64="release_assets/$APPIMAGE_X64_NAME"
|
||||
echo "Downloading x64 AppImage from $URL_X64"
|
||||
curl -L -o "$PATH_X64" "$URL_X64"
|
||||
chmod +x "$PATH_X64"
|
||||
CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')
|
||||
|
||||
APPIMAGE_ARM64_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}')
|
||||
APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
|
||||
URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
|
||||
PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
|
||||
echo "Downloading arm64 AppImage from $URL_ARM64"
|
||||
curl -L -o "$PATH_ARM64" "$URL_ARM64"
|
||||
chmod +x "$PATH_ARM64"
|
||||
CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')
|
||||
|
||||
echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
|
||||
@@ -690,10 +767,6 @@ jobs:
|
||||
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:
|
||||
@@ -703,8 +776,8 @@ jobs:
|
||||
|
||||
submit-to-homebrew:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: [build-macos]
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -720,19 +793,28 @@ jobs:
|
||||
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
|
||||
- name: Download and prepare DMG info from public release
|
||||
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}')
|
||||
DMG_NAME="termix_macos_universal_dmg.dmg"
|
||||
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
||||
|
||||
mkdir -p release_asset
|
||||
DOWNLOAD_PATH="release_asset/$DMG_NAME"
|
||||
echo "Downloading DMG from $URL"
|
||||
|
||||
if command -v curl &> /dev/null; then
|
||||
curl -L -o "$DOWNLOAD_PATH" "$URL"
|
||||
elif command -v wget &> /dev/null; then
|
||||
wget -O "$DOWNLOAD_PATH" "$URL"
|
||||
else
|
||||
echo "Neither curl nor wget is available, installing curl"
|
||||
brew install curl
|
||||
curl -L -o "$DOWNLOAD_PATH" "$URL"
|
||||
fi
|
||||
|
||||
CHECKSUM=$(shasum -a 256 "$DOWNLOAD_PATH" | awk '{print $1}')
|
||||
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
@@ -745,23 +827,15 @@ jobs:
|
||||
|
||||
mkdir -p homebrew-submission/Casks/t
|
||||
|
||||
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Verify Cask syntax
|
||||
run: |
|
||||
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:
|
||||
@@ -789,10 +863,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Display artifact structure
|
||||
run: |
|
||||
ls -R artifacts/
|
||||
|
||||
- name: Upload artifacts to latest release
|
||||
run: |
|
||||
cd artifacts
|
||||
@@ -808,3 +878,131 @@ jobs:
|
||||
done
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
submit-to-testflight:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
for i in 1 2 3;
|
||||
do
|
||||
if npm ci; then
|
||||
break
|
||||
else
|
||||
if [ $i -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
npm install --force @rollup/rollup-darwin-arm64
|
||||
npm install dmg-license
|
||||
|
||||
- name: Check for Code Signing Certificates
|
||||
id: check_certs
|
||||
run: |
|
||||
if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
|
||||
echo "has_certs=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificates
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
|
||||
MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
|
||||
MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
|
||||
INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH
|
||||
|
||||
if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
|
||||
echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
|
||||
fi
|
||||
|
||||
security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
|
||||
if [ -f "$INSTALLER_CERT_PATH" ]; then
|
||||
security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
fi
|
||||
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
security find-identity -v -p codesigning $KEYCHAIN_PATH
|
||||
|
||||
- name: Build macOS App Store Package
|
||||
if: steps.check_certs.outputs.has_certs == 'true'
|
||||
env:
|
||||
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
|
||||
- name: Check for App Store Connect API credentials
|
||||
id: check_asc_creds
|
||||
run: |
|
||||
if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
|
||||
echo "has_credentials=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Ruby for Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
bundler-cache: false
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
|
||||
- name: Deploy to App Store Connect (TestFlight)
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
echo "PKG file not found, exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||
|
||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||
--type macos \
|
||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||
continue-on-error: true
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Generate OpenAPI Specification
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-openapi:
|
||||
name: Generate OpenAPI JSON
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: npm run generate:openapi
|
||||
|
||||
- name: Upload OpenAPI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openapi-spec
|
||||
path: openapi.json
|
||||
retention-days: 90
|
||||
@@ -6,7 +6,10 @@ on:
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -28,3 +28,4 @@ dist-ssr
|
||||
/.mcp.json
|
||||
/nul
|
||||
/.vscode/
|
||||
/CLAUDE.md
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
cask "termix" do
|
||||
version "VERSION_PLACEHOLDER"
|
||||
sha256 "CHECKSUM_PLACEHOLDER"
|
||||
version "2.0.0"
|
||||
sha256 "2e381ac504093bfa72d16ee20043640724394729b2ca0e6b26c9eac19aeb6d08"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_#{version}_dmg.dmg"
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
|
||||
homepage "https://github.com/Termix-SSH/Termix"
|
||||
@@ -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,8 +1,7 @@
|
||||
# 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>
|
||||
🇺🇸 English · <a href="readme/README-CN.md">🇨🇳 中文</a> · <a href="readme/README-JA.md">🇯🇵 日本語</a> · <a href="readme/README-KO.md">🇰🇷 한국어</a> · <a href="readme/README-FR.md">🇫🇷 Français</a> · <a href="readme/README-DE.md">🇩🇪 Deutsch</a> · <a href="readme/README-ES.md">🇪🇸 Español</a> · <a href="readme/README-PT.md">🇧🇷 Português</a> · <a href="readme/README-RU.md">🇷🇺 Русский</a> · <a href="readme/README-AR.md">🇸🇦 العربية</a> · <a href="readme/README-HI.md">🇮🇳 हिन्दी</a> · <a href="readme/README-TR.md">🇹🇷 Türkçe</a> · <a href="readme/README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="readme/README-IT.md">🇮🇹 Italiano</a>
|
||||
</p>
|
||||
|
||||

|
||||
@@ -16,26 +15,12 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
If you would like, you can support the project here!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Overview
|
||||
|
||||
<p align="center">
|
||||
@@ -45,28 +30,34 @@ If you would like, you can support the project here!\
|
||||
|
||||
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
|
||||
access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote SSH file management, and many other tools. Termix is the perfect
|
||||
free and self-hosted alternative to Termius available for all platforms.
|
||||
|
||||
# Features
|
||||
|
||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
|
||||
- **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 Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
|
||||
- **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening
|
||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections
|
||||
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
|
||||
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
|
||||
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
|
||||
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
|
||||
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers
|
||||
- **Dashboard** - View server information at a glance on your dashboard
|
||||
- **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.
|
||||
- **RBAC** - Create roles and share hosts across users/roles
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **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.
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between many different UI themes including light, dark, Dracula, etc. Use URL routes to open any connection in full-screen.
|
||||
- **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations))
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Quick Connect** - Connect to a server without having to save the connection data
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support
|
||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
||||
|
||||
# Planned Features
|
||||
|
||||
@@ -76,29 +67,30 @@ See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned fe
|
||||
|
||||
Supported Devices:
|
||||
|
||||
- Website (any modern browser on any platform like Chrome, Safari, and Firefox)
|
||||
- Website (any modern browser on any platform like Chrome, Safari, and Firefox) (includes PWA support)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager (coming soon)
|
||||
- Chocolatey Package Manager
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak (coming soon)
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 on v12.0+)
|
||||
- Apple App Store (coming soon)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew (coming soon)
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- ISO
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
||||
a sample Docker Compose file here:
|
||||
a sample Docker Compose file here (you can omit guacd and the network if you don't plan on using remote desktop features):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -112,19 +104,70 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Sponsors
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Crowdflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 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.
|
||||
|
||||
# Show-off
|
||||
# Screenshots
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
@@ -143,14 +186,20 @@ channel, however, response times may be longer.
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="./repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</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>
|
||||
<img src="./repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="./repo-images/Image 10.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
Videos and images may be out of date.
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 11.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="./repo-images/Image 12.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Some videos and images may be out of date or may not perfectly showcase features.
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /src/locales/en.json
|
||||
translation: /src/locales/translated/%locale_with_underscore%.json
|
||||
@@ -6,8 +6,7 @@ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN rm -rf node_modules package-lock.json && \
|
||||
npm install --ignore-scripts --force && \
|
||||
RUN npm ci --ignore-scripts --force && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -19,7 +18,7 @@ COPY . .
|
||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||
|
||||
RUN npm cache clean --force && \
|
||||
npm run build
|
||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
@@ -51,18 +50,21 @@ ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl && \
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
|
||||
update-ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
useradd -r -s /bin/false nginx
|
||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \
|
||||
chown -R node:node /app && \
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx && \
|
||||
touch /app/nginx/nginx.conf && \
|
||||
chown node:node /app/nginx/nginx.conf
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
||||
COPY docker/nginx.conf /app/nginx/nginx.conf.template
|
||||
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template
|
||||
|
||||
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
|
||||
COPY --chown=node:node --from=frontend-builder /app/dist /app/html
|
||||
COPY --chown=node:node --from=frontend-builder /app/src/locales /app/html/locales
|
||||
COPY --chown=node:node --from=frontend-builder /app/public/fonts /app/html/fonts
|
||||
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
@@ -72,6 +74,10 @@ VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
termix-dev:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: termix-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- termix-dev-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: development
|
||||
depends_on:
|
||||
- guacd-dev
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
guacd-dev:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd-dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
volumes:
|
||||
termix-dev-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-dev-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
@@ -1,6 +1,27 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if [ "$PUID" = "0" ]; then
|
||||
echo "Running as root (PUID=0, PGID=$PGID)"
|
||||
chown -R root:root /app/data /app/uploads /app/nginx 2>/dev/null || true
|
||||
else
|
||||
echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..."
|
||||
|
||||
groupmod -o -g "$PGID" node 2>/dev/null || true
|
||||
usermod -o -u "$PUID" node 2>/dev/null || true
|
||||
|
||||
chown -R node:node /app/data /app/uploads /app/nginx 2>/dev/null || true
|
||||
|
||||
echo "User node is now UID: $PUID, GID: $PGID"
|
||||
|
||||
exec gosu node:node "$0" "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
export PORT=${PORT:-8080}
|
||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
@@ -11,24 +32,43 @@ 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"
|
||||
NGINX_CONF_SOURCE="/app/nginx/nginx-https.conf.template"
|
||||
else
|
||||
echo "SSL disabled - using HTTP-only configuration (default)"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
|
||||
NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template"
|
||||
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
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /app/nginx/nginx.conf
|
||||
|
||||
mkdir -p /app/data /app/uploads
|
||||
chown -R node:node /app/data /app/uploads
|
||||
chmod 755 /app/data /app/uploads
|
||||
mkdir -p /app/data /app/uploads /app/data/.opk
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||
|
||||
if [ -w /app/data ]; then
|
||||
echo "Data directory is writable"
|
||||
else
|
||||
echo "WARNING: Data directory is not writable. OPKSSH may fail."
|
||||
ls -ld /app/data
|
||||
fi
|
||||
|
||||
if [ -w /app/data/.opk ]; then
|
||||
echo "OPKSSH directory is writable"
|
||||
else
|
||||
echo "WARNING: OPKSSH directory is not writable. OPKSSH authentication will fail."
|
||||
ls -ld /app/data/.opk
|
||||
fi
|
||||
|
||||
OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh"
|
||||
if [ ! -d "$OPKSSH_DIR" ]; then
|
||||
echo "WARNING: OPKSSH binary directory not found at $OPKSSH_DIR"
|
||||
echo "OPKSSH will be downloaded automatically on first use."
|
||||
else
|
||||
echo "OPKSSH binary directory found at $OPKSSH_DIR"
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "Checking SSL certificate configuration..."
|
||||
mkdir -p /app/data/ssl
|
||||
chown -R node:node /app/data/ssl
|
||||
chmod 755 /app/data/ssl
|
||||
chmod 755 /app/data/ssl 2>/dev/null || true
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-localhost}
|
||||
|
||||
@@ -84,7 +124,6 @@ EOF
|
||||
|
||||
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
|
||||
|
||||
@@ -93,7 +132,7 @@ EOF
|
||||
fi
|
||||
|
||||
echo "Starting nginx..."
|
||||
nginx
|
||||
nginx -c /app/nginx/nginx.conf
|
||||
|
||||
echo "Starting backend services..."
|
||||
cd /app
|
||||
@@ -110,11 +149,7 @@ else
|
||||
echo "Warning: package.json not found"
|
||||
fi
|
||||
|
||||
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
|
||||
node dist/backend/backend/starter.js
|
||||
|
||||
echo "All services started"
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /app/nginx/logs/access.log;
|
||||
|
||||
client_body_temp_path /app/nginx/client_body;
|
||||
proxy_temp_path /app/nginx/proxy_temp;
|
||||
fastcgi_temp_path /app/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /app/nginx/uwsgi_temp;
|
||||
scgi_temp_path /app/nginx/scgi_temp;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
@@ -13,6 +26,21 @@ http {
|
||||
set_real_ip_from 127.0.0.1;
|
||||
real_ip_header X-Forwarded-For;
|
||||
|
||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_host $proxy_x_forwarded_host {
|
||||
default $http_x_forwarded_host;
|
||||
'' $http_host;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_port $proxy_x_forwarded_port {
|
||||
default $http_x_forwarded_port;
|
||||
'' '';
|
||||
}
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
@@ -37,9 +65,17 @@ http {
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
root /app/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.map$ {
|
||||
@@ -51,25 +87,27 @@ http {
|
||||
location ~ ^/users/sessions(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
}
|
||||
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -78,7 +116,7 @@ http {
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -87,7 +125,16 @@ http {
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -96,7 +143,7 @@ http {
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -109,7 +156,7 @@ http {
|
||||
location ~ ^/snippets(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -118,7 +165,7 @@ http {
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -130,7 +177,7 @@ http {
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -149,7 +196,7 @@ http {
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -165,16 +212,58 @@ http {
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
location /host/quick-connect {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-callback(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -186,12 +275,14 @@ http {
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
@@ -203,49 +294,94 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30008/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/guacamole(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/recent {
|
||||
location /host/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
location /host/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
location /host/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
location /host/file_manager/sudo-password {
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -258,10 +394,40 @@ http {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/file_manager/ssh/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/network-topology(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -270,7 +436,7 @@ http {
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -279,7 +445,20 @@ http {
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location ~ ^/global-settings(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -288,7 +467,7 @@ http {
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -297,15 +476,62 @@ http {
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
proxy_pass http://127.0.0.1:30009/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
root /app/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /app/nginx/logs/access.log;
|
||||
|
||||
client_body_temp_path /app/nginx/client_body;
|
||||
proxy_temp_path /app/nginx/proxy_temp;
|
||||
fastcgi_temp_path /app/nginx/fastcgi_temp;
|
||||
uwsgi_temp_path /app/nginx/uwsgi_temp;
|
||||
scgi_temp_path /app/nginx/scgi_temp;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
client_header_timeout 300s;
|
||||
@@ -13,6 +26,21 @@ http {
|
||||
set_real_ip_from 127.0.0.1;
|
||||
real_ip_header X-Forwarded-For;
|
||||
|
||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
'' $scheme;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_host $proxy_x_forwarded_host {
|
||||
default $http_x_forwarded_host;
|
||||
'' $http_host;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_port $proxy_x_forwarded_port {
|
||||
default $http_x_forwarded_port;
|
||||
'' '';
|
||||
}
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
@@ -27,14 +55,14 @@ http {
|
||||
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;
|
||||
root /app/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
@@ -48,25 +76,27 @@ http {
|
||||
location ~ ^/users/sessions(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/users(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
}
|
||||
|
||||
location ~ ^/version(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -75,7 +105,7 @@ http {
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -84,7 +114,16 @@ http {
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -93,7 +132,7 @@ http {
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -106,7 +145,7 @@ http {
|
||||
location ~ ^/snippets(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -115,7 +154,7 @@ http {
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -127,7 +166,7 @@ http {
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -146,7 +185,7 @@ http {
|
||||
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -162,16 +201,58 @@ http {
|
||||
location ~ ^/encryption(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/ {
|
||||
location /host/quick-connect {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-callback(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
}
|
||||
|
||||
location /host/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -183,12 +264,14 @@ http {
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
@@ -200,49 +283,94 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30008/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/guacamole(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/recent {
|
||||
location /host/file_manager/recent {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/pinned {
|
||||
location /host/file_manager/pinned {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/shortcuts {
|
||||
location /host/file_manager/shortcuts {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ssh/ {
|
||||
location /host/file_manager/sudo-password {
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -255,10 +383,40 @@ http {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /host/file_manager/ssh/ {
|
||||
client_max_body_size 5G;
|
||||
client_body_timeout 300s;
|
||||
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
|
||||
proxy_pass http://127.0.0.1:30004;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/network-topology(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -267,7 +425,7 @@ http {
|
||||
location ~ ^/status(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -276,7 +434,20 @@ http {
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location ~ ^/global-settings(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -285,7 +456,7 @@ http {
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
@@ -294,15 +465,62 @@ http {
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30006;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
proxy_pass http://127.0.0.1:30009/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
root /app/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,29 @@
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"asar": false,
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"public/**/*",
|
||||
"!**/node_modules/**/*",
|
||||
"!src/**/*",
|
||||
"!*.md",
|
||||
"!tsconfig*.json",
|
||||
"!vite.config.ts",
|
||||
"!eslint.config.js"
|
||||
"!eslint.config.js",
|
||||
"!node_modules/@napi-rs/canvas*/**/*",
|
||||
"!node_modules/@rollup/rollup-darwin-*/**/*",
|
||||
"!node_modules/@rollup/rollup-linux-*/**/*",
|
||||
"!node_modules/@rollup/rollup-win32-*/**/*",
|
||||
"!dist/icon-mac.png",
|
||||
"!public/icon-mac.png",
|
||||
"!dist/icon.ico",
|
||||
"!public/icon.ico",
|
||||
"!dist/icon.icns",
|
||||
"!public/icon.icns",
|
||||
"!dist/icons/**/*",
|
||||
"!public/icons/**/*"
|
||||
],
|
||||
"asarUnpack": ["node_modules/node-fetch/**/*"],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
@@ -103,7 +114,10 @@
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
"minimumSystemVersion": "10.15"
|
||||
"minimumSystemVersion": "10.15",
|
||||
"mergeASARs": false,
|
||||
"singleArchFiles": "**/*.node",
|
||||
"x64ArchFiles": "**/*.node"
|
||||
},
|
||||
"dmg": {
|
||||
"artifactName": "termix_macos_${arch}_dmg.${ext}",
|
||||
@@ -116,7 +130,7 @@
|
||||
"entitlementsInherit": "build/entitlements.mas.inherit.plist",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"asarUnpack": ["**/*.node"],
|
||||
"asar": false,
|
||||
"type": "distribution",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
@@ -124,5 +138,6 @@
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"generateUpdatesFilesForAllChannels": true
|
||||
}
|
||||
|
||||
@@ -5,19 +5,81 @@ const {
|
||||
ipcMain,
|
||||
dialog,
|
||||
Menu,
|
||||
Tray,
|
||||
} = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const https = require("https");
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
const { fork } = require("child_process");
|
||||
|
||||
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
||||
function logToFile(...args) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const msg = args
|
||||
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
||||
.join(" ");
|
||||
const line = `[${timestamp}] ${msg}\n`;
|
||||
try {
|
||||
fs.appendFileSync(logFile, line);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
function httpFetch(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === "https:";
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const requestOptions = {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
};
|
||||
|
||||
if (isHttps) {
|
||||
requestOptions.rejectUnauthorized = false;
|
||||
requestOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: false,
|
||||
checkServerIdentity: () => undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||||
status: res.statusCode,
|
||||
text: () => Promise.resolve(data),
|
||||
json: () => Promise.resolve(JSON.parse(data)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Request timeout"));
|
||||
});
|
||||
|
||||
if (options.body) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("--no-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-setuid-sandbox");
|
||||
app.commandLine.appendSwitch("--disable-dev-shm-usage");
|
||||
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.commandLine.appendSwitch("--disable-gpu");
|
||||
app.commandLine.appendSwitch("--disable-gpu-compositing");
|
||||
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
||||
@@ -26,10 +88,148 @@ app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
||||
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
let mainWindow = null;
|
||||
let backendProcess = null;
|
||||
let tray = null;
|
||||
let isQuitting = false;
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||
|
||||
function getBackendEntryPath() {
|
||||
if (isDev) {
|
||||
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
||||
}
|
||||
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
||||
}
|
||||
|
||||
function getBackendDataDir() {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const dataDir = path.join(userDataPath, "server-data");
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
function startBackendServer() {
|
||||
return new Promise((resolve) => {
|
||||
const entryPath = getBackendEntryPath();
|
||||
|
||||
logToFile("isDev:", isDev, "appRoot:", appRoot);
|
||||
logToFile("app.isPackaged:", app.isPackaged);
|
||||
logToFile("process.env.NODE_ENV:", process.env.NODE_ENV);
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
logToFile("Backend entry not found:", entryPath);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = getBackendDataDir();
|
||||
logToFile("Starting embedded backend server...");
|
||||
logToFile("Backend entry:", entryPath);
|
||||
logToFile("Data directory:", dataDir);
|
||||
logToFile("Backend cwd:", appRoot);
|
||||
|
||||
logToFile("Checking paths...");
|
||||
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
||||
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
||||
logToFile(" appRoot exists:", fs.existsSync(appRoot));
|
||||
|
||||
const distPath = path.join(appRoot, "dist");
|
||||
if (fs.existsSync(distPath)) {
|
||||
logToFile(" dist directory contents:", fs.readdirSync(distPath));
|
||||
const backendPath = path.join(distPath, "backend");
|
||||
if (fs.existsSync(backendPath)) {
|
||||
logToFile(" dist/backend contents:", fs.readdirSync(backendPath));
|
||||
}
|
||||
}
|
||||
|
||||
backendProcess = fork(entryPath, [], {
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DATA_DIR: dataDir,
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
PORT: "30001",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
|
||||
logToFile("Backend process spawned, pid:", backendProcess.pid);
|
||||
|
||||
let resolved = false;
|
||||
const readyTimeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
logToFile("Backend ready timeout (15s), proceeding anyway...");
|
||||
resolve(true);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
backendProcess.stdout.on("data", (data) => {
|
||||
const msg = data.toString().trim();
|
||||
logToFile("[backend]", msg);
|
||||
if (!resolved && msg.includes("started successfully")) {
|
||||
resolved = true;
|
||||
clearTimeout(readyTimeout);
|
||||
logToFile("Backend ready signal received");
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
backendProcess.stderr.on("data", (data) => {
|
||||
logToFile("[backend:stderr]", data.toString().trim());
|
||||
});
|
||||
|
||||
backendProcess.on("exit", (code, signal) => {
|
||||
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
||||
backendProcess = null;
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(readyTimeout);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
backendProcess.on("error", (err) => {
|
||||
logToFile("Failed to start backend process:", err.message);
|
||||
backendProcess = null;
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(readyTimeout);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stopBackendServer() {
|
||||
if (!backendProcess) return;
|
||||
|
||||
console.log("Stopping embedded backend server...");
|
||||
|
||||
try {
|
||||
backendProcess.send({ type: "shutdown" });
|
||||
} catch {
|
||||
// IPC channel may already be closed
|
||||
}
|
||||
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (backendProcess) {
|
||||
console.log("Force killing backend process...");
|
||||
backendProcess.kill("SIGKILL");
|
||||
backendProcess = null;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
backendProcess.on("exit", () => {
|
||||
clearTimeout(forceKillTimeout);
|
||||
backendProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
console.log("Another instance is already running, quitting...");
|
||||
@@ -45,6 +245,62 @@ if (!gotTheLock) {
|
||||
});
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
try {
|
||||
const { nativeImage } = require("electron");
|
||||
|
||||
let trayIcon;
|
||||
if (process.platform === "darwin") {
|
||||
const iconPath = path.join(appRoot, "public", "icons", "16x16.png");
|
||||
trayIcon = nativeImage.createFromPath(iconPath);
|
||||
trayIcon.setTemplateImage(true);
|
||||
} else if (process.platform === "win32") {
|
||||
trayIcon = path.join(appRoot, "public", "icon.ico");
|
||||
} else {
|
||||
trayIcon = path.join(appRoot, "public", "icons", "32x32.png");
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip("Termix");
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show Window",
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("click", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("System tray created successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to create system tray:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const appVersion = app.getVersion();
|
||||
const electronVersion = process.versions.electron;
|
||||
@@ -75,6 +331,19 @@ function createWindow() {
|
||||
show: true,
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.setPermissionRequestHandler(
|
||||
(webContents, permission, callback) => {
|
||||
if (
|
||||
permission === "clipboard-read" ||
|
||||
permission === "clipboard-sanitized-write"
|
||||
) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
callback(true);
|
||||
},
|
||||
);
|
||||
|
||||
if (process.platform !== "darwin") {
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
@@ -187,6 +456,13 @@ function createWindow() {
|
||||
console.log("Frontend loaded successfully");
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (!isQuitting && tray && !tray.isDestroyed()) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
@@ -219,65 +495,7 @@ async function fetchGitHubAPI(endpoint, cacheKey) {
|
||||
}
|
||||
|
||||
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}`, {
|
||||
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixElectronUpdateChecker/1.0",
|
||||
@@ -362,6 +580,14 @@ ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-embedded-server-status", () => {
|
||||
return {
|
||||
running: backendProcess !== null && !backendProcess.killed,
|
||||
embedded: !isDev,
|
||||
dataDir: isDev ? null : getBackendDataDir(),
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
@@ -437,67 +663,76 @@ ipcMain.handle("set-setting", (event, key, value) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-iframe-jwt", async () => {
|
||||
try {
|
||||
if (!mainWindow) return null;
|
||||
const frames = mainWindow.webContents.mainFrame.framesInSubtree;
|
||||
logToFile(`[get-iframe-jwt] scanning ${frames.length} frames`);
|
||||
for (const frame of frames) {
|
||||
if (frame === mainWindow.webContents.mainFrame) continue;
|
||||
try {
|
||||
const token = await frame.executeJavaScript(
|
||||
`(function() {
|
||||
try {
|
||||
const t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt');
|
||||
return t || null;
|
||||
} catch(e) { return null; }
|
||||
})()`,
|
||||
);
|
||||
logToFile(
|
||||
`[get-iframe-jwt] frame url=${frame.url} token found=${!!token} length=${token?.length}`,
|
||||
);
|
||||
if (token && token.length > 20) return token;
|
||||
} catch (err) {
|
||||
logToFile(`[get-iframe-jwt] frame exec error:`, err.message);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logToFile("[get-iframe-jwt] error:", error.message);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-session-cookie", async (_event, name) => {
|
||||
try {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (!ses) return null;
|
||||
const cookies = await ses.cookies.get({ name });
|
||||
return cookies.length > 0 ? cookies[0].value : null;
|
||||
} catch (error) {
|
||||
console.error("Failed to get session cookie:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-session-cookies", async () => {
|
||||
try {
|
||||
const ses = mainWindow?.webContents?.session;
|
||||
if (ses) {
|
||||
const cookies = await ses.cookies.get({});
|
||||
for (const cookie of cookies) {
|
||||
const scheme = cookie.secure ? "https" : "http";
|
||||
const domain = cookie.domain?.startsWith(".")
|
||||
? cookie.domain.slice(1)
|
||||
: cookie.domain || "localhost";
|
||||
const url = `${scheme}://${domain}${cookie.path || "/"}`;
|
||||
await ses.cookies.remove(url, cookie.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear session cookies:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const 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, {
|
||||
const response = await httpFetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -543,7 +778,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
|
||||
try {
|
||||
const versionUrl = `${normalizedServerUrl}/version`;
|
||||
const response = await fetch(versionUrl, {
|
||||
const response = await httpFetch(versionUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
});
|
||||
@@ -660,13 +895,36 @@ function createMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
app.whenReady().then(async () => {
|
||||
logToFile("=== App ready ===");
|
||||
logToFile(
|
||||
"isDev:",
|
||||
isDev,
|
||||
"platform:",
|
||||
process.platform,
|
||||
"arch:",
|
||||
process.arch,
|
||||
);
|
||||
createMenu();
|
||||
|
||||
if (!isDev) {
|
||||
const result = await startBackendServer();
|
||||
logToFile("startBackendServer result:", result);
|
||||
} else {
|
||||
logToFile(
|
||||
"Skipping embedded backend (isDev=true) - expecting separate dev:backend process",
|
||||
);
|
||||
}
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
app.quit();
|
||||
if (!tray || tray.isDestroyed()) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
@@ -675,8 +933,13 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
stopBackendServer();
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
const { clipboard } = 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,
|
||||
@@ -25,7 +11,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||
|
||||
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("electronClipboard", {
|
||||
writeText: (text) => clipboard.writeText(text),
|
||||
readText: () => clipboard.readText(),
|
||||
});
|
||||
|
||||
window.IS_ELECTRON = true;
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist"]),
|
||||
globalIgnores(["dist", "release", "Mobile"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
@@ -19,5 +19,13 @@ export default tseslint.config([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-expressions": "warn",
|
||||
"no-empty": "warn",
|
||||
"no-control-regex": "off",
|
||||
"react-refresh/only-export-components": "warn",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Termix
|
||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||
Exec=termix %U
|
||||
Exec=run.sh %U
|
||||
Icon=com.karmaa.termix
|
||||
Terminal=false
|
||||
Type=Application
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[Flatpak Ref]
|
||||
Name=Termix
|
||||
Branch=stable
|
||||
Title=Termix - SSH Server Management Platform
|
||||
IsRuntime=false
|
||||
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
|
||||
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
|
||||
Icon=https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png
|
||||
Homepage=https://github.com/Termix-SSH/Termix
|
||||
@@ -5,7 +5,7 @@
|
||||
<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>
|
||||
<project_license>Apache-2.0</project_license>
|
||||
|
||||
<developer_name>bugattiguy527</developer_name>
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
app-id: com.karmaa.termix
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "23.08"
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
base: org.electronjs.Electron2.BaseApp
|
||||
base-version: "23.08"
|
||||
command: termix
|
||||
base-version: "24.08"
|
||||
command: run.sh
|
||||
separate-locales: false
|
||||
|
||||
finish-args:
|
||||
@@ -16,8 +16,11 @@ finish-args:
|
||||
- --device=dri
|
||||
- --filesystem=home
|
||||
- --socket=ssh-auth
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --socket=session-bus
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --env=ELECTRON_TRASH=gio
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
- --env=ELECTRON_OZONE_PLATFORM_HINT=auto
|
||||
|
||||
modules:
|
||||
- name: termix
|
||||
@@ -30,6 +33,21 @@ modules:
|
||||
- cp -r squashfs-root/resources /app/bin/
|
||||
- cp -r squashfs-root/locales /app/bin/ || true
|
||||
|
||||
- cp squashfs-root/*.so /app/bin/ || true
|
||||
- cp squashfs-root/*.pak /app/bin/ || true
|
||||
- cp squashfs-root/*.bin /app/bin/ || true
|
||||
- cp squashfs-root/*.dat /app/bin/ || true
|
||||
- cp squashfs-root/*.json /app/bin/ || true
|
||||
|
||||
- |
|
||||
cat > run.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
|
||||
exec zypak-wrapper /app/bin/termix "$@"
|
||||
EOF
|
||||
- chmod +x run.sh
|
||||
- install -Dm755 run.sh /app/bin/run.sh
|
||||
|
||||
- install -Dm644 com.karmaa.termix.desktop /app/share/applications/com.karmaa.termix.desktop
|
||||
|
||||
- install -Dm644 com.karmaa.termix.metainfo.xml /app/share/metainfo/com.karmaa.termix.metainfo.xml
|
||||
@@ -40,14 +58,14 @@ modules:
|
||||
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage
|
||||
sha256: CHECKSUM_X64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
- x86_64
|
||||
|
||||
- type: file
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_VERSION_PLACEHOLDER_appimage.AppImage
|
||||
url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage
|
||||
sha256: CHECKSUM_ARM64_PLACEHOLDER
|
||||
dest-filename: termix.AppImage
|
||||
only-arches:
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="Termix" />
|
||||
<link rel="apple-touch-icon" href="/icons/512x512.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Termix</title>
|
||||
<style>
|
||||
.hide-scrollbar {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "2.1.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -17,24 +17,60 @@
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
|
||||
"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"
|
||||
"electron:rebuild": "electron-rebuild -f -w better-sqlite3",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && npm run electron:rebuild && electron-builder --linux --dir",
|
||||
"build:linux-appimage": "npm run build && npm run electron:rebuild && electron-builder --linux AppImage",
|
||||
"build:linux-targz": "npm run build && npm run electron:rebuild && electron-builder --linux tar.gz",
|
||||
"build:mac": "npm run build && npm run electron:rebuild && electron-builder --mac --universal"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"multer": "^2.0.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"node-fetch": "^3.3.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@electron/rebuild": "^3.7.2",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
@@ -45,50 +81,62 @@
|
||||
"@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-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-grid-layout": "^1.3.6",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-theme-github": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.24.1",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"axios": "^1.10.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"chalk": "^4.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cytoscape": "^3.33.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",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lint-staged": "^16.2.3",
|
||||
"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",
|
||||
"prettier": "3.6.2",
|
||||
"react": "^19.1.0",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-h5-audio-player": "^3.10.1",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
@@ -96,7 +144,6 @@
|
||||
"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",
|
||||
@@ -104,42 +151,13 @@
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"wait-on": "^9.0.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.3",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.5"
|
||||
"vite": "^7.1.5",
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 447 B |
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 671 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 986 B After Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Termix",
|
||||
"short_name": "Termix",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"theme_color": "#09090b",
|
||||
"background_color": "#09090b",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/64x64.png",
|
||||
"sizes": "64x64",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./icons/512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "developer", "productivity"]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
const CACHE_NAME = "termix-v1";
|
||||
const STATIC_ASSETS = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/manifest.json",
|
||||
"/favicon.ico",
|
||||
"/icons/48x48.png",
|
||||
"/icons/128x128.png",
|
||||
"/icons/256x256.png",
|
||||
"/icons/512x512.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
})
|
||||
.then(() => {
|
||||
return self.skipWaiting();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME)
|
||||
.map((name) => {
|
||||
return caches.delete(name);
|
||||
}),
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
return self.clients.claim();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== "GET") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/ws")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/host/opkssh-chooser/") ||
|
||||
url.pathname.startsWith("/host/opkssh-callback/")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
return caches.match("/index.html");
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isStaticAsset = STATIC_ASSETS.some((asset) => {
|
||||
if (asset === "/") return url.pathname === "/";
|
||||
return url.pathname === asset || url.pathname.startsWith("/assets/");
|
||||
});
|
||||
|
||||
if (!isStaticAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
return fetch(request).then((response) => {
|
||||
if (!response || response.status !== 200 || response.type !== "basic") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
# إحصائيات المستودع
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">تم تحقيقه في 1 سبتمبر 2025</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، والتحكم في سطح المكتب البعيد (RDP، VNC، Telnet)، وقدرات إنشاء أنفاق SSH، وإدارة ملفات SSH عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات.
|
||||
|
||||
# الميزات
|
||||
|
||||
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى.
|
||||
- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة.
|
||||
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r.
|
||||
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo.
|
||||
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
|
||||
- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
|
||||
- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux.
|
||||
- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم.
|
||||
- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
|
||||
- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
|
||||
- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات.
|
||||
- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات.
|
||||
- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS.
|
||||
- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين العديد من سمات واجهة المستخدم بما في ذلك الفاتح والداكن و Dracula وغيرها. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة.
|
||||
- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)).
|
||||
- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS، يمكن تشغيله بشكل مستقل بدون خادم Termix الخلفي)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android.
|
||||
- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة.
|
||||
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً.
|
||||
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال.
|
||||
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح.
|
||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ.
|
||||
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة.
|
||||
- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم.
|
||||
|
||||
# الميزات المخططة
|
||||
|
||||
راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# التثبيت
|
||||
|
||||
الأجهزة المدعومة:
|
||||
|
||||
- الموقع الإلكتروني (أي متصفح حديث على أي منصة مثل Chrome و Safari و Firefox) (يتضمن دعم PWA)
|
||||
- Windows (x64/ia32)
|
||||
- نسخة محمولة
|
||||
- مثبت MSI
|
||||
- مدير حزم Chocolatey
|
||||
- Linux (x64/ia32)
|
||||
- نسخة محمولة
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 على الإصدار 12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (الإصدار 15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (الإصدار 7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا (يمكنك حذف guacd والشبكة إذا كنت لا تخطط لاستخدام ميزات سطح المكتب البعيد):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# الرعاة
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# الدعم
|
||||
|
||||
إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`.
|
||||
يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول.
|
||||
|
||||
# لقطات الشاشة
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي.
|
||||
|
||||
# الترخيص
|
||||
|
||||
موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف LICENSE لمزيد من المعلومات.
|
||||
@@ -0,0 +1,217 @@
|
||||
# 仓库统计
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文 ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">获得于 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 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能、远程 SSH 文件管理以及许多其他工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
||||
|
||||
# 功能
|
||||
|
||||
- **SSH 终端访问** - 功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。
|
||||
- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。
|
||||
- **SSH 隧道管理** - 创建和管理具有自动重连和健康监测功能的 SSH 隧道,支持 -l 或 -r 连接。
|
||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。
|
||||
- **Docker 管理** - 启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
|
||||
- **SSH 主机管理器** - 通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
|
||||
- **服务器统计** - 在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙和端口监控。
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息。
|
||||
- **RBAC** - 创建角色并在用户/角色之间共享主机。
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据。
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向。
|
||||
- **现代 UI** - 使用 React、Tailwind CSS 和 Shadcn 构建的整洁的桌面/移动友好界面。有多种 UI 主题可选,包括浅色、深色、Dracula 等。使用 URL 路由全屏打开任何连接。
|
||||
- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。
|
||||
- **平台支持** - 提供 Web 应用、桌面应用(Windows、Linux 和 macOS,可脱离 Termix 后端独立运行)、PWA 以及 iOS 和 Android 专用移动/平板应用。
|
||||
- **SSH 工具** - 创建可重用的命令片段,只需点击一下即可执行。在多个打开的终端中同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看之前运行过的 SSH 命令。
|
||||
- **快速连接** - 无需保存连接数据即可连接到服务器。
|
||||
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接。
|
||||
- **丰富的功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等。
|
||||
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,并支持状态监测。
|
||||
- **持久标签页** - 如果在用户个人资料中启用,SSH 会话和标签页将在设备/刷新后保持打开状态。
|
||||
|
||||
# 计划功能
|
||||
|
||||
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
|
||||
# 安装
|
||||
|
||||
支持的设备:
|
||||
|
||||
- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持)
|
||||
- Windows (x64/ia32)
|
||||
- 便携版
|
||||
- MSI 安装程序
|
||||
- Chocolatey 软件包管理器
|
||||
- Linux (x64/ia32)
|
||||
- 便携版
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32, v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play 商店
|
||||
- APK
|
||||
|
||||
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。此外,这里有一个示例 Docker Compose 文件(如果您不打算使用远程桌面功能,可以省略 guacd 和网络部分):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# 赞助商
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 支持
|
||||
|
||||
如果您需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
||||
请尽可能详细地描述您的问题,建议使用英语。您也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持频道,但响应时间可能较长。
|
||||
|
||||
# 展示
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
某些视频和图像可能已过时,或者可能无法完美展示功能。
|
||||
|
||||
# 许可证
|
||||
|
||||
根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。
|
||||
@@ -0,0 +1,217 @@
|
||||
# Repo-Statistiken
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Erreicht am 1. September 2025</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>
|
||||
|
||||
Wenn Sie möchten, können Sie das Projekt hier unterstützen!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Überblick
|
||||
|
||||
<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 ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, Remote-Desktop-Steuerung (RDP, VNC, Telnet), SSH-Tunneling-Funktionen, Remote-SSH-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen.
|
||||
|
||||
# Funktionen
|
||||
|
||||
- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten.
|
||||
- **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen.
|
||||
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen.
|
||||
- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
|
||||
- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
|
||||
- **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren.
|
||||
- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen.
|
||||
- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen.
|
||||
- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen.
|
||||
- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen.
|
||||
- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security).
|
||||
- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren.
|
||||
- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen.
|
||||
- **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen vielen verschiedenen UI-Themes einschließlich Hell, Dunkel, Dracula usw. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen.
|
||||
- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS, kann eigenständig ohne Termix-Backend ausgeführt werden), PWA und dedizierte Mobil-/Tablet-App für iOS und Android.
|
||||
- **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus.
|
||||
- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle.
|
||||
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen.
|
||||
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen.
|
||||
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw.
|
||||
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren.
|
||||
- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert.
|
||||
|
||||
# Geplante Funktionen
|
||||
|
||||
Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) für alle geplanten Funktionen. Wenn Sie beitragen möchten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Installation
|
||||
|
||||
Unterstützte Geräte:
|
||||
|
||||
- Website (jeder moderne Browser auf jeder Plattform wie Chrome, Safari und Firefox) (einschließlich PWA-Unterstützung)
|
||||
- Windows (x64/ia32)
|
||||
- Portabel
|
||||
- MSI-Installationsprogramm
|
||||
- Chocolatey-Paketmanager
|
||||
- Linux (x64/ia32)
|
||||
- Portabel
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 ab v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei (Sie können guacd und das Netzwerk weglassen, wenn Sie keine Remote-Desktop-Funktionen nutzen möchten):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Sponsoren
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Support
|
||||
|
||||
Wenn Sie Hilfe benötigen oder eine Funktion für Termix anfragen möchten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`.
|
||||
Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf Englisch. Sie können auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings können die Antwortzeiten dort länger sein.
|
||||
|
||||
# Screenshots
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Einige Videos und Bilder können veraltet sein oder Funktionen möglicherweise nicht perfekt darstellen.
|
||||
|
||||
# Lizenz
|
||||
|
||||
Verteilt unter der Apache License Version 2.0. Siehe LICENSE für weitere Informationen.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Estadísticas del Repositorio
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Logrado el 1 de septiembre de 2025</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>
|
||||
|
||||
Si lo desea, puede apoyar el proyecto aquí.\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Descripción General
|
||||
|
||||
<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 es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, control de escritorio remoto (RDP, VNC, Telnet), capacidades de túneles SSH, gestión remota de archivos SSH y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
|
||||
|
||||
# Características
|
||||
|
||||
- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes.
|
||||
- **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida.
|
||||
- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r.
|
||||
- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
|
||||
- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
|
||||
- **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH.
|
||||
- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux.
|
||||
- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard.
|
||||
- **RBAC** - Cree roles y comparta hosts entre usuarios/roles.
|
||||
- **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí.
|
||||
- **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información.
|
||||
- **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos.
|
||||
- **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS.
|
||||
- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre muchos temas de UI diferentes, incluyendo claro, oscuro, Dracula, etc. Use rutas URL para abrir cualquier conexión en pantalla completa.
|
||||
- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS, se puede ejecutar de forma independiente sin el backend de Termix), PWA y aplicación dedicada para móviles/tablets en iOS y Android.
|
||||
- **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos.
|
||||
- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente.
|
||||
- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión.
|
||||
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado.
|
||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado.
|
||||
- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario.
|
||||
|
||||
# Características Planeadas
|
||||
|
||||
Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las características planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Instalación
|
||||
|
||||
Dispositivos soportados:
|
||||
|
||||
- Sitio web (cualquier navegador moderno en cualquier plataforma como Chrome, Safari y Firefox) (incluye soporte PWA)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- Instalador MSI
|
||||
- Gestor de paquetes Chocolatey
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 en v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí (puede omitir guacd y la red si no planea usar funciones de escritorio remoto):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Patrocinadores
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Soporte
|
||||
|
||||
Si necesita ayuda o desea solicitar una función para Termix, visite la página de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesión y pulse `New Issue`.
|
||||
Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito en inglés. También puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser más largos.
|
||||
|
||||
# Capturas de Pantalla
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Algunos videos e imágenes pueden estar desactualizados o no mostrar perfectamente las características.
|
||||
|
||||
# Licencia
|
||||
|
||||
Distribuido bajo la Licencia Apache Versión 2.0. Consulte LICENSE para más información.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Statistiques du dépôt
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Obtenu le 1er septembre 2025</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>
|
||||
|
||||
Si vous le souhaitez, vous pouvez soutenir le projet ici !\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Présentation
|
||||
|
||||
<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 est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, le contrôle de bureau à distance (RDP, VNC, Telnet), des capacités de tunneling SSH, la gestion de fichiers SSH à distance et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes.
|
||||
|
||||
# Fonctionnalités
|
||||
|
||||
- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants.
|
||||
- **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé.
|
||||
- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r.
|
||||
- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo.
|
||||
- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer.
|
||||
- **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH.
|
||||
- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux.
|
||||
- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord.
|
||||
- **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles.
|
||||
- **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC (avec contrôle d'accès) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble.
|
||||
- **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails.
|
||||
- **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers.
|
||||
- **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS.
|
||||
- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez parmi de nombreux thèmes d'interface utilisateur, notamment clair, sombre, Dracula, etc. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran.
|
||||
- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS, peut être exécutée de manière autonome sans backend Termix), PWA, et application mobile/tablette dédiée pour iOS et Android.
|
||||
- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts.
|
||||
- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées.
|
||||
- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion.
|
||||
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier.
|
||||
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts.
|
||||
- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur.
|
||||
|
||||
# Fonctionnalités prévues
|
||||
|
||||
Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalités prévues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Installation
|
||||
|
||||
Appareils supportés :
|
||||
|
||||
- Site web (tout navigateur moderne sur toute plateforme comme Chrome, Safari et Firefox) (support PWA inclus)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- Installateur MSI
|
||||
- Gestionnaire de paquets Chocolatey
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 sur v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose (vous pouvez omettre guacd et le réseau si vous ne prévoyez pas d'utiliser les fonctionnalités de bureau à distance) :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Sponsors
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Support
|
||||
|
||||
Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez être aussi détaillé que possible dans votre issue, de préférence rédigée en anglais. Vous pouvez également rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de réponse peuvent être plus longs.
|
||||
|
||||
# Captures d'écran
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Certaines vidéos et images peuvent être obsolètes ou ne pas présenter parfaitement les fonctionnalités.
|
||||
|
||||
# Licence
|
||||
|
||||
Distribué sous la licence Apache Version 2.0. Consultez LICENSE pour plus d'informations.
|
||||
@@ -0,0 +1,217 @@
|
||||
# रिपॉजिटरी आँकड़े
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">1 सितंबर, 2025 को प्राप्त</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 टर्मिनल एक्सेस, रिमोट डेस्कटॉप कंट्रोल (RDP, VNC, Telnet), SSH टनलिंग क्षमताएँ, रिमोट SSH फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
|
||||
|
||||
# विशेषताएँ
|
||||
|
||||
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है।
|
||||
- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ।
|
||||
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ।
|
||||
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें।
|
||||
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
|
||||
- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
|
||||
- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें।
|
||||
- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें।
|
||||
- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
|
||||
- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
|
||||
- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें।
|
||||
- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें।
|
||||
- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन।
|
||||
- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। लाइट, डार्क, ड्रैकुला आदि सहित कई अलग-अलग UI थीम के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें।
|
||||
- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)।
|
||||
- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS, Termix बैकएंड के बिना स्टैंडअलोन चलाया जा सकता है), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध।
|
||||
- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ।
|
||||
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य।
|
||||
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें।
|
||||
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें।
|
||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट।
|
||||
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें।
|
||||
- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं।
|
||||
|
||||
# नियोजित विशेषताएँ
|
||||
|
||||
सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
|
||||
|
||||
# इंस्टॉलेशन
|
||||
|
||||
समर्थित डिवाइस:
|
||||
|
||||
- वेबसाइट (किसी भी प्लेटफ़ॉर्म पर कोई भी आधुनिक ब्राउज़र जैसे Chrome, Safari, और Firefox) (PWA सपोर्ट सहित)
|
||||
- Windows (x64/ia32)
|
||||
- पोर्टेबल
|
||||
- MSI इंस्टॉलर
|
||||
- Chocolatey पैकेज मैनेजर
|
||||
- Linux (x64/ia32)
|
||||
- पोर्टेबल
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (v12.0+ पर x64/ia32)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें (यदि आप रिमोट डेस्कटॉप सुविधाओं का उपयोग करने की योजना नहीं बना रहे हैं तो आप guacd और नेटवर्क को हटा सकते हैं):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# प्रायोजक
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# सहायता
|
||||
|
||||
यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ।
|
||||
कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है।
|
||||
|
||||
# स्क्रीनशॉट
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं।
|
||||
|
||||
# लाइसेंस
|
||||
|
||||
Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए LICENSE देखें।
|
||||
@@ -0,0 +1,217 @@
|
||||
# Statistiche Repo
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano
|
||||
</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;">Ottenuto il 1 settembre 2025</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>
|
||||
|
||||
Se lo desideri, puoi supportare il progetto qui!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Panoramica
|
||||
|
||||
<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 è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, controllo remoto del desktop (RDP, VNC, Telnet), funzionalità di tunneling SSH, gestione remota dei file SSH e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
|
||||
|
||||
# Funzionalità
|
||||
|
||||
- **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni.
|
||||
- **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso.
|
||||
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r.
|
||||
- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
|
||||
- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
||||
- **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
|
||||
- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux.
|
||||
- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard.
|
||||
- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli.
|
||||
- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
|
||||
- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
|
||||
- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file.
|
||||
- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS.
|
||||
- **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra molti temi UI diversi, inclusi chiaro, scuro, Dracula, ecc. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero.
|
||||
- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)).
|
||||
- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS, può essere eseguito in modo autonomo senza il backend Termix), PWA e app dedicata per mobile/tablet su iOS e Android.
|
||||
- **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti.
|
||||
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza.
|
||||
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione.
|
||||
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera.
|
||||
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc.
|
||||
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato.
|
||||
- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente.
|
||||
|
||||
# Funzionalità Pianificate
|
||||
|
||||
Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Installazione
|
||||
|
||||
Dispositivi Supportati:
|
||||
|
||||
- Sito web (qualsiasi browser moderno su qualsiasi piattaforma come Chrome, Safari e Firefox) (include supporto PWA)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 su v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui (puoi omettere guacd e la rete se non prevedi di utilizzare le funzioni di desktop remoto):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Sponsor
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Supporto
|
||||
|
||||
Se hai bisogno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`.
|
||||
Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi.
|
||||
|
||||
# Screenshot
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità.
|
||||
|
||||
# Licenza
|
||||
|
||||
Distribuito sotto la Licenza Apache Versione 2.0. Consulta LICENSE per maggiori informazioni.
|
||||
@@ -0,0 +1,216 @@
|
||||
# リポジトリ統計
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語 ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">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ターミナルアクセス、リモートデスクトップ制御(RDP、VNC、Telnet)、SSHトンネリング機能、リモートSSHファイル管理、およびその他多くのツールを提供します。Termixは、すべてのプラットフォームで利用可能なTermiusの完全無料でセルフホスト可能な代替ソリューションです。
|
||||
|
||||
# 機能
|
||||
|
||||
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。
|
||||
- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。
|
||||
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応しています。
|
||||
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。
|
||||
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
|
||||
- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
|
||||
- **サーバー統計** - ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。
|
||||
- **ダッシュボード** - ダッシュボードでサーバー情報を一目で確認できます。
|
||||
- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有できます。
|
||||
- **ユーザー認証** - 管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
|
||||
- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。
|
||||
- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です。
|
||||
- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です。
|
||||
- **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ライト、ダーク、Draculaなど、多くの異なるUIテーマから選択可能。URLルートで任意の接続をフルスクリーンで開くことができます。
|
||||
- **多言語対応** - 約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。
|
||||
- **プラットフォーム対応** - Webアプリ、デスクトップアプリケーション(Windows、Linux、macOS。Termixバックエンドなしでスタンドアロン動作可能)、PWA、iOS・Android専用モバイル/タブレットアプリとして利用可能です。
|
||||
- **SSHツール** - ワンクリックで実行できる再利用可能なコマンドスニペットの作成。複数の開いているターミナルに対して同時にコマンドを実行できます。
|
||||
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です。
|
||||
- **クイック接続** - 接続データを保存せずにサーバーに接続できます。
|
||||
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます。
|
||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています。
|
||||
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化できます。
|
||||
- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます。
|
||||
|
||||
# 予定されている機能
|
||||
|
||||
すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
# インストール
|
||||
|
||||
対応デバイス:
|
||||
|
||||
- Webサイト(Chrome、Safari、Firefoxなど、あらゆるプラットフォームのモダンブラウザ)(PWA対応)
|
||||
- Windows (x64/ia32)
|
||||
- ポータブル版
|
||||
- MSIインストーラー
|
||||
- Chocolateyパッケージマネージャー
|
||||
- Linux (x64/ia32)
|
||||
- ポータブル版
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32、v12.0以降)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1以降)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0以降)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
すべてのプラットフォームへのTermixのインストール方法については、Termixの[ドキュメント](https://docs.termix.site/install)をご覧ください。または、以下のサンプルDocker Composeファイルをご覧ください(リモートデスクトップ機能を使用する予定がない場合は、guacdとネットワークの設定を省略できます):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# スポンサー
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# サポート
|
||||
|
||||
Termixに関するヘルプや機能リクエストが必要な場合は、[Issues](https://github.com/Termix-SSH/Support/issues)ページにアクセスし、ログインして`New Issue`を押してください。Issueはできるだけ詳細に記述し、英語での記述が望ましいです。また、[Discord](https://discord.gg/jVQGdvHDrf)サーバーに参加してサポートチャンネルを利用することもできますが、応答時間が長くなる場合があります。
|
||||
|
||||
# スクリーンショット
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。
|
||||
|
||||
# ライセンス
|
||||
|
||||
Apache License Version 2.0のもとで配布されています。詳細はLICENSEをご覧ください。
|
||||
@@ -0,0 +1,216 @@
|
||||
# 리포지토리 통계
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어 ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">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 터미널 접속, 원격 데스크톱 제어(RDP, VNC, Telnet), SSH 터널링 기능, 원격 SSH 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다.
|
||||
|
||||
# 기능
|
||||
|
||||
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원.
|
||||
- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원.
|
||||
- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원.
|
||||
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행.
|
||||
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
|
||||
- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
|
||||
- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시.
|
||||
- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인.
|
||||
- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유.
|
||||
- **사용자 인증** - 관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
|
||||
- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요.
|
||||
- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기.
|
||||
- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리.
|
||||
- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 라이트, 다크, 드라큘라 등 다양한 UI 테마 선택 가능. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능.
|
||||
- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리).
|
||||
- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS, Termix 백엔드 없이 독립 실행 가능), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공.
|
||||
- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행.
|
||||
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회.
|
||||
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속.
|
||||
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근.
|
||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원.
|
||||
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화.
|
||||
- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지.
|
||||
|
||||
# 계획된 기능
|
||||
|
||||
모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
|
||||
|
||||
# 설치
|
||||
|
||||
지원 기기:
|
||||
|
||||
- 웹사이트 (Chrome, Safari, Firefox 등 모든 플랫폼의 최신 브라우저) (PWA 지원 포함)
|
||||
- Windows (x64/ia32)
|
||||
- 포터블
|
||||
- MSI 설치 프로그램
|
||||
- Chocolatey 패키지 관리자
|
||||
- Linux (x64/ia32)
|
||||
- 포터블
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32, v12.0 이상)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1 이상)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0 이상)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다(원격 데스크톱 기능을 사용할 계획이 없다면 guacd와 네트워크를 생략할 수 있습니다):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# 스폰서
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# 지원
|
||||
|
||||
Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다.
|
||||
|
||||
# 스크린샷
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다.
|
||||
|
||||
# 라이선스
|
||||
|
||||
Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 LICENSE를 참조하세요.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Estatísticas do Repositório
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Conquistado em 1 de setembro de 2025</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>
|
||||
|
||||
Se desejar, você pode apoiar o projeto aqui!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Visão Geral
|
||||
|
||||
<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 é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, controle de desktop remoto (RDP, VNC, Telnet), capacidades de tunelamento SSH, gerenciamento remoto de arquivos SSH e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas.
|
||||
|
||||
# Funcionalidades
|
||||
|
||||
- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes.
|
||||
- **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida
|
||||
- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r
|
||||
- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
|
||||
- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
|
||||
- **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH
|
||||
- **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux
|
||||
- **Dashboard** - Visualize informações do servidor de relance no seu dashboard
|
||||
- **RBAC** - Crie funções e compartilhe hosts entre usuários/funções
|
||||
- **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si.
|
||||
- **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações.
|
||||
- **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos
|
||||
- **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS
|
||||
- **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre muitos temas de interface diferentes, incluindo claro, escuro, Drácula, etc. Use rotas de URL para abrir qualquer conexão em tela cheia.
|
||||
- **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations))
|
||||
- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS, pode ser executado de forma independente sem o backend Termix), PWA e aplicativo dedicado para celular/tablet para iOS e Android.
|
||||
- **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos.
|
||||
- **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
|
||||
- **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão
|
||||
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
|
||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
|
||||
- **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário
|
||||
|
||||
# Funcionalidades Planejadas
|
||||
|
||||
Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se você deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Instalação
|
||||
|
||||
Dispositivos suportados:
|
||||
|
||||
- Website (qualquer navegador moderno em qualquer plataforma como Chrome, Safari e Firefox) (inclui suporte PWA)
|
||||
- Windows (x64/ia32)
|
||||
- Portátil
|
||||
- Instalador MSI
|
||||
- Gerenciador de pacotes Chocolatey
|
||||
- Linux (x64/ia32)
|
||||
- Portátil
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 em v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui (você pode omitir o guacd e a rede se não planeja usar recursos de área de trabalho remota):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Patrocinadores
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Suporte
|
||||
|
||||
Se você precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a página de [Issues](https://github.com/Termix-SSH/Support/issues), faça login e clique em `New Issue`.
|
||||
Por favor, seja o mais detalhado possível no seu relato, preferencialmente escrito em inglês. Você também pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porém, os tempos de resposta podem ser mais longos.
|
||||
|
||||
# Capturas de Tela
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<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"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades.
|
||||
|
||||
# Licença
|
||||
|
||||
Distribuído sob a Licença Apache Versão 2.0. Consulte LICENSE para mais informações.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Статистика репозитория
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Достигнуто 1 сентября 2025 года</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-терминалу, управление удаленным рабочим столом (RDP, VNC, Telnet), возможности SSH-туннелирования, удаленное управление файлами SSH и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ.
|
||||
|
||||
# Возможности
|
||||
|
||||
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты.
|
||||
- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана
|
||||
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
|
||||
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo.
|
||||
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
|
||||
- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей
|
||||
- **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux
|
||||
- **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом
|
||||
- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей
|
||||
- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
|
||||
- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security)
|
||||
- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера
|
||||
- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS
|
||||
- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме.
|
||||
- **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations))
|
||||
- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS, может работать автономно без бэкенда Termix), PWA и специализированное мобильное/планшетное приложение для iOS и Android.
|
||||
- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах.
|
||||
- **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд
|
||||
- **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения
|
||||
- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
|
||||
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др.
|
||||
- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
|
||||
- **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя
|
||||
|
||||
# Запланированные функции
|
||||
|
||||
Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Установка
|
||||
|
||||
Поддерживаемые устройства:
|
||||
|
||||
- Веб-сайт (любой современный браузер на любой платформе, включая Chrome, Safari и Firefox) (включая поддержку PWA)
|
||||
- Windows (x64/ia32)
|
||||
- Портативная версия
|
||||
- Установщик MSI
|
||||
- Менеджер пакетов Chocolatey
|
||||
- Linux (x64/ia32)
|
||||
- Портативная версия
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32, версия 12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (версия 15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (версия 7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь (вы можете опустить guacd и сеть, если не планируете использовать функции удаленного рабочего стола):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Спонсоры
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Поддержка
|
||||
|
||||
Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`.
|
||||
Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше.
|
||||
|
||||
# Скриншоты
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<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"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.
|
||||
|
||||
# Лицензия
|
||||
|
||||
Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле LICENSE.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Repo İstatistikleri
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe ·
|
||||
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">1 Eylül 2025'te kazanıldı</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>
|
||||
|
||||
Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Genel Bakış
|
||||
|
||||
<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, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, uzak masaüstü kontrolü (RDP, VNC, Telnet), SSH tünelleme yetenekleri, uzak SSH dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir.
|
||||
|
||||
# Özellikler
|
||||
|
||||
- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir.
|
||||
- **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği
|
||||
- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle
|
||||
- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
|
||||
- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
|
||||
- **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin
|
||||
- **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin
|
||||
- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin
|
||||
- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın
|
||||
- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC (erişim kontrollü) ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın.
|
||||
- **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın.
|
||||
- **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın
|
||||
- **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi
|
||||
- **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Işık, karanlık, Dracula vb. dahil olmak üzere birçok farklı UI teması arasından seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın.
|
||||
- **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir)
|
||||
- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS, Termix arka ucu olmadan tek başına çalıştırılabilir), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir.
|
||||
- **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın.
|
||||
- **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin
|
||||
- **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın
|
||||
- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
|
||||
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler.
|
||||
- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
|
||||
- **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır
|
||||
|
||||
# Planlanan Özellikler
|
||||
|
||||
Tüm planlanan özellikler için [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasına bakın. Katkıda bulunmak istiyorsanız, [Katkıda Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasına bakın.
|
||||
|
||||
# Kurulum
|
||||
|
||||
Desteklenen Cihazlar:
|
||||
|
||||
- Web sitesi (Chrome, Safari ve Firefox gibi herhangi bir platformda herhangi bir modern tarayıcı) (PWA desteği dahil)
|
||||
- Windows (x64/ia32)
|
||||
- Taşınabilir
|
||||
- MSI Yükleyici
|
||||
- Chocolatey Paket Yöneticisi
|
||||
- Linux (x64/ia32)
|
||||
- Taşınabilir
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (v12.0+ üzerinde x64/ia32)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin (uzak masaüstü özelliklerini kullanmayı planlamıyorsanız guacd'yi ve ağı çıkarabilirsiniz):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Sponsorlar
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Destek
|
||||
|
||||
Termix ile ilgili yardıma ihtiyacınız varsa veya bir özellik talep etmek istiyorsanız, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasını ziyaret edin, giriş yapın ve `New Issue` butonuna basın.
|
||||
Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce olarak. Ayrıca [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katılabilir ve destek kanalını ziyaret edebilirsiniz, ancak yanıt süreleri daha uzun olabilir.
|
||||
|
||||
# Ekran Görüntüleri
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<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"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir.
|
||||
|
||||
# Lisans
|
||||
|
||||
Apache Lisansı Sürüm 2.0 altında dağıtılmaktadır. Daha fazla bilgi için LICENSE dosyasına bakın.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Thống Kê Repo
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
|
||||
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||
<img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt ·
|
||||
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</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;">Đạt được vào ngày 1 tháng 9 năm 2025</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>
|
||||
|
||||
Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\
|
||||
[](https://github.com/sponsors/LukeGus)
|
||||
|
||||
# Tổng Quan
|
||||
|
||||
<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 là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, điều khiển máy tính từ xa (RDP, VNC, Telnet), khả năng tạo đường hầm SSH, quản lý tệp SSH từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng.
|
||||
|
||||
# Tính Năng
|
||||
|
||||
- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác.
|
||||
- **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình
|
||||
- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r
|
||||
- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
|
||||
- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
|
||||
- **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH
|
||||
- **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux
|
||||
- **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn
|
||||
- **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò
|
||||
- **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC (có kiểm soát truy cập) và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau.
|
||||
- **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm.
|
||||
- **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp
|
||||
- **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS
|
||||
- **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa nhiều chủ đề UI khác nhau bao gồm sáng, tối, Dracula, v.v. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình.
|
||||
- **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations))
|
||||
- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS, có thể chạy độc lập mà không cần backend Termix), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android.
|
||||
- **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở.
|
||||
- **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó
|
||||
- **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối
|
||||
- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
|
||||
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v.
|
||||
- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
|
||||
- **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng
|
||||
|
||||
# Tính Năng Dự Kiến
|
||||
|
||||
Xem [Dự Án](https://github.com/orgs/Termix-SSH/projects/2) để biết tất cả các tính năng dự kiến. Nếu bạn muốn đóng góp, xem [Đóng Góp](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
# Cài Đặt
|
||||
|
||||
Thiết Bị Được Hỗ Trợ:
|
||||
|
||||
- Trang web (bất kỳ trình duyệt hiện đại nào trên bất kỳ nền tảng nào như Chrome, Safari và Firefox) (bao gồm hỗ trợ PWA)
|
||||
- Windows (x64/ia32)
|
||||
- Portable
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager
|
||||
- Linux (x64/ia32)
|
||||
- Portable
|
||||
- AUR
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
- macOS (x64/ia32 trên v12.0+)
|
||||
- Apple App Store
|
||||
- DMG
|
||||
- Homebrew
|
||||
- iOS/iPadOS (v15.1+)
|
||||
- Apple App Store
|
||||
- IPA
|
||||
- Android (v7.0+)
|
||||
- Google Play Store
|
||||
- APK
|
||||
|
||||
Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây (bạn có thể bỏ qua guacd và mạng nếu không có ý định sử dụng các tính năng điều khiển máy tính từ xa):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
termix:
|
||||
image: ghcr.io/lukegus/termix:latest
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
volumes:
|
||||
termix-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
termix-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
# Nhà Tài Trợ
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.digitalocean.com/">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||
</a>
|
||||
|
||||
<a href="https://crowdin.com/">
|
||||
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||
</a>
|
||||
|
||||
<a href="https://www.blacksmith.sh/">
|
||||
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||
</a>
|
||||
|
||||
<a href="https://www.cloudflare.com/">
|
||||
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||
</a>
|
||||
|
||||
<a href="https://tailscale.com/">
|
||||
<img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="50" alt="TailScale">
|
||||
</a>
|
||||
|
||||
<a href="https://akamai.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="50" alt="Akamai">
|
||||
</a>
|
||||
|
||||
<a href="https://aws.amazon.com/">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="50" alt="AWS">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# Hỗ Trợ
|
||||
|
||||
Nếu bạn cần trợ giúp hoặc muốn yêu cầu tính năng với Termix, hãy truy cập trang [Vấn Đề](https://github.com/Termix-SSH/Support/issues), đăng nhập và nhấn `New Issue`.
|
||||
Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết bằng tiếng Anh. Bạn cũng có thể tham gia máy chủ [Discord](https://discord.gg/jVQGdvHDrf) và truy cập kênh hỗ trợ, tuy nhiên thời gian phản hồi có thể lâu hơn.
|
||||
|
||||
# Ảnh Chụp Màn Hình
|
||||
|
||||
[](https://www.youtube.com/@TermixSSH/videos)
|
||||
|
||||
<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"/>
|
||||
<img src="../repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
|
||||
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/>
|
||||
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/>
|
||||
</p>
|
||||
|
||||
Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng.
|
||||
|
||||
# Giấy Phép
|
||||
|
||||
Được phân phối theo Giấy Phép Apache Phiên Bản 2.0. Xem LICENSE để biết thêm thông tin.
|
||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 407 KiB After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 418 KiB |
@@ -1,9 +1,14 @@
|
||||
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 { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
hosts,
|
||||
hostAccess,
|
||||
dashboardPreferences,
|
||||
} from "./database/db/schema.js";
|
||||
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
@@ -15,49 +20,43 @@ const authManager = AuthManager.getInstance();
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
|
||||
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(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /uptime:
|
||||
* get:
|
||||
* summary: Get server uptime
|
||||
* description: Returns the uptime of the server in various formats.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server uptime information.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* uptimeMs:
|
||||
* type: number
|
||||
* uptimeSeconds:
|
||||
* type: number
|
||||
* formatted:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get uptime.
|
||||
*/
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
@@ -77,6 +76,28 @@ app.get("/uptime", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/recent:
|
||||
* get:
|
||||
* summary: Get recent activity
|
||||
* description: Fetches the most recent activities for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The maximum number of activities to return.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent activities.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to get recent activity.
|
||||
*/
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -108,6 +129,40 @@ app.get("/activity/recent", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/log:
|
||||
* post:
|
||||
* summary: Log a new activity
|
||||
* description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||
* hostId:
|
||||
* type: integer
|
||||
* hostName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Activity logged successfully or rate-limited.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 404:
|
||||
* description: Host not found or access denied.
|
||||
* 500:
|
||||
* description: Failed to log activity.
|
||||
*/
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -127,9 +182,21 @@ app.post("/activity/log", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
if (
|
||||
![
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
"telnet",
|
||||
"vnc",
|
||||
"rdp",
|
||||
].includes(type)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,17 +222,28 @@ app.post("/activity/log", async (req, res) => {
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const hosts = await SimpleDBOps.select(
|
||||
const ownedHosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
if (ownedHosts.length === 0) {
|
||||
const sharedHosts = await getDb()
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
);
|
||||
|
||||
if (sharedHosts.length === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
@@ -180,21 +258,48 @@ app.post("/activity/log", async (req, res) => {
|
||||
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,
|
||||
);
|
||||
// Best-effort trim of old activity entries; failures here should not
|
||||
// cause the primary /activity/log request to 500.
|
||||
try {
|
||||
const allActivities = await SimpleDBOps.select<{
|
||||
id: number;
|
||||
timestamp: string;
|
||||
}>(
|
||||
getDb()
|
||||
.select({
|
||||
id: recentActivity.id,
|
||||
timestamp: recentActivity.timestamp,
|
||||
})
|
||||
.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);
|
||||
if (allActivities.length > 100) {
|
||||
const idsToDelete = allActivities
|
||||
.slice(100)
|
||||
.map((a) => a.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
inArray(recentActivity.id, idsToDelete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
userId,
|
||||
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
@@ -204,6 +309,22 @@ app.post("/activity/log", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /activity/reset:
|
||||
* delete:
|
||||
* summary: Reset recent activity
|
||||
* description: Clears all recent activity for the authenticated user.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent activity cleared.
|
||||
* 401:
|
||||
* description: Session expired.
|
||||
* 500:
|
||||
* description: Failed to reset activity.
|
||||
*/
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -233,6 +354,160 @@ app.delete("/activity/reset", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/preferences:
|
||||
* get:
|
||||
* summary: Get dashboard layout preferences
|
||||
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Dashboard preferences retrieved
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* cards:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* order:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: Session expired
|
||||
* 500:
|
||||
* description: Failed to get preferences
|
||||
*/
|
||||
app.get("/dashboard/preferences", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const preferences = await getDb()
|
||||
.select()
|
||||
.from(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
|
||||
if (preferences.length === 0) {
|
||||
const defaultLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1 },
|
||||
{ id: "recent_activity", enabled: true, order: 2 },
|
||||
{ id: "network_graph", enabled: false, order: 3 },
|
||||
{ id: "quick_actions", enabled: true, order: 4 },
|
||||
{ id: "server_stats", enabled: true, order: 5 },
|
||||
],
|
||||
};
|
||||
return res.json(defaultLayout);
|
||||
}
|
||||
|
||||
const layout = JSON.parse(preferences[0].layout as string);
|
||||
res.json(layout);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get dashboard preferences", err);
|
||||
res.status(500).json({ error: "Failed to get dashboard preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/preferences:
|
||||
* post:
|
||||
* summary: Save dashboard layout preferences
|
||||
* description: Saves or updates the user's customized dashboard layout settings.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* cards:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* order:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences saved successfully
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* 401:
|
||||
* description: Session expired
|
||||
* 500:
|
||||
* description: Failed to save preferences
|
||||
*/
|
||||
app.post("/dashboard/preferences", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { cards } = req.body;
|
||||
|
||||
if (!cards || !Array.isArray(cards)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid request body. Expected { cards: Array }",
|
||||
});
|
||||
}
|
||||
|
||||
const layout = JSON.stringify({ cards });
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(dashboardPreferences)
|
||||
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
} else {
|
||||
await getDb().insert(dashboardPreferences).values({ userId, layout });
|
||||
}
|
||||
|
||||
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
|
||||
|
||||
dashboardLogger.success("Dashboard preferences saved", {
|
||||
operation: "save_dashboard_preferences",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Dashboard preferences saved" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to save dashboard preferences", err);
|
||||
res.status(500).json({ error: "Failed to save dashboard preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
import userRoutes from "./routes/users.js";
|
||||
import sshRoutes from "./routes/ssh.js";
|
||||
import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import cors from "cors";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -23,9 +27,10 @@ import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import {
|
||||
users,
|
||||
sshData,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
@@ -40,8 +45,12 @@ import type {
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { getDb } from "./db/index.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -50,44 +59,7 @@ app.set("trust proxy", true);
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://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",
|
||||
"Accept",
|
||||
"Origin",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
@@ -164,12 +136,14 @@ async function fetchGitHubAPI<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
|
||||
const url = `${GITHUB_API_BASE}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -203,11 +177,51 @@ app.use(bodyParser.json({ limit: "1gb" }));
|
||||
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
|
||||
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
|
||||
app.use(cookieParser());
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /health:
|
||||
* get:
|
||||
* summary: Health check
|
||||
* description: Returns the health status of the server.
|
||||
* tags:
|
||||
* - General
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server is healthy.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
*/
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /version:
|
||||
* get:
|
||||
* summary: Get version information
|
||||
* description: Returns the local and remote version of the application.
|
||||
* tags:
|
||||
* - General
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Version information.
|
||||
* 404:
|
||||
* description: Local version not set.
|
||||
* 500:
|
||||
* description: Fetch error.
|
||||
*/
|
||||
app.get("/version", authenticateJWT, async (req, res) => {
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
@@ -262,6 +276,10 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).send("Local Version Not Set");
|
||||
}
|
||||
|
||||
if (req.query.checkRemote === "false") {
|
||||
return res.json({ localVersion, status: "update_check_disabled" });
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI<GitHubRelease>(
|
||||
@@ -306,6 +324,31 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /releases/rss:
|
||||
* get:
|
||||
* summary: Get releases in RSS format
|
||||
* description: Returns the latest releases from the GitHub repository in an RSS-like JSON format.
|
||||
* tags:
|
||||
* - General
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The page number of the releases to fetch.
|
||||
* - in: query
|
||||
* name: per_page
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: The number of releases to fetch per page.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Releases in RSS format.
|
||||
* 500:
|
||||
* description: Failed to generate RSS format.
|
||||
*/
|
||||
app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
@@ -362,6 +405,20 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /encryption/status:
|
||||
* get:
|
||||
* summary: Get encryption status
|
||||
* description: Returns the security status of the application.
|
||||
* tags:
|
||||
* - Encryption
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Security status.
|
||||
* 500:
|
||||
* description: Failed to get security status.
|
||||
*/
|
||||
app.get("/encryption/status", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const securityStatus = {
|
||||
@@ -383,6 +440,20 @@ app.get("/encryption/status", requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /encryption/initialize:
|
||||
* post:
|
||||
* summary: Initialize security system
|
||||
* description: Initializes the security system for the application.
|
||||
* tags:
|
||||
* - Encryption
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Security system initialized successfully.
|
||||
* 500:
|
||||
* description: Failed to initialize security system.
|
||||
*/
|
||||
app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -406,6 +477,20 @@ app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /encryption/regenerate:
|
||||
* post:
|
||||
* summary: Regenerate JWT secret
|
||||
* description: Regenerates the system JWT secret. This will invalidate all existing JWT tokens.
|
||||
* tags:
|
||||
* - Encryption
|
||||
* responses:
|
||||
* 200:
|
||||
* description: System JWT secret regenerated.
|
||||
* 500:
|
||||
* description: Failed to regenerate JWT secret.
|
||||
*/
|
||||
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
apiLogger.warn("System JWT secret regenerated via API", {
|
||||
@@ -427,6 +512,20 @@ app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /encryption/regenerate-jwt:
|
||||
* post:
|
||||
* summary: Regenerate JWT secret
|
||||
* description: Regenerates the JWT secret. This will invalidate all existing JWT tokens.
|
||||
* tags:
|
||||
* - Encryption
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New JWT secret generated.
|
||||
* 500:
|
||||
* description: Failed to regenerate JWT secret.
|
||||
*/
|
||||
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
apiLogger.warn("JWT secret regenerated via API", {
|
||||
@@ -447,25 +546,61 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/export:
|
||||
* post:
|
||||
* summary: Export user data
|
||||
* description: Exports the user's data as a SQLite database file.
|
||||
* tags:
|
||||
* - Database
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User data exported successfully.
|
||||
* 400:
|
||||
* description: Password required for export.
|
||||
* 401:
|
||||
* description: Invalid password.
|
||||
* 500:
|
||||
* description: Failed to export user data.
|
||||
*/
|
||||
app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for export",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!user[0].isOidc;
|
||||
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.info("Exporting user data as SQLite", {
|
||||
@@ -478,15 +613,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
|
||||
const tempDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports")
|
||||
: path.join(os.tmpdir(), "termix-exports");
|
||||
const tempDir = path.join(os.tmpdir(), "termix-exports");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -542,6 +669,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
@@ -550,19 +678,46 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
tags TEXT,
|
||||
pin INTEGER NOT NULL DEFAULT 0,
|
||||
auth_type TEXT NOT NULL,
|
||||
force_keyboard_interactive TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
sudo_password TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
credential_id INTEGER,
|
||||
override_credential_username INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
jump_hosts TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1,
|
||||
show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER NOT NULL DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
mac_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -575,7 +730,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
@@ -641,29 +796,29 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
userRecord.id,
|
||||
userRecord.username,
|
||||
"[EXPORTED_USER_NO_PASSWORD]",
|
||||
userRecord.is_admin ? 1 : 0,
|
||||
userRecord.is_oidc ? 1 : 0,
|
||||
userRecord.oidc_identifier || null,
|
||||
userRecord.client_id || null,
|
||||
userRecord.client_secret || null,
|
||||
userRecord.issuer_url || null,
|
||||
userRecord.authorization_url || null,
|
||||
userRecord.token_url || null,
|
||||
userRecord.identifier_path || null,
|
||||
userRecord.name_path || null,
|
||||
userRecord.isAdmin ? 1 : 0,
|
||||
userRecord.isOidc ? 1 : 0,
|
||||
userRecord.oidcIdentifier || null,
|
||||
userRecord.clientId || null,
|
||||
userRecord.clientSecret || null,
|
||||
userRecord.issuerUrl || null,
|
||||
userRecord.authorizationUrl || null,
|
||||
userRecord.tokenUrl || null,
|
||||
userRecord.identifierPath || null,
|
||||
userRecord.namePath || null,
|
||||
userRecord.scopes || null,
|
||||
userRecord.totp_secret || null,
|
||||
userRecord.totp_enabled ? 1 : 0,
|
||||
userRecord.totp_backup_codes || null,
|
||||
userRecord.totpSecret || null,
|
||||
userRecord.totpEnabled ? 1 : 0,
|
||||
userRecord.totpBackupCodes || null,
|
||||
);
|
||||
|
||||
const sshHosts = await getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.userId, userId));
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
const insertHost = exportDb.prepare(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, password, key, key_password, key_type, autostart_password, autostart_key, autostart_key_password, credential_id, enable_terminal, enable_tunnel, tunnel_connections, enable_file_manager, default_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const host of sshHosts) {
|
||||
@@ -676,6 +831,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
insertHost.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
decrypted.connectionType || "ssh",
|
||||
decrypted.name || null,
|
||||
decrypted.ip,
|
||||
decrypted.port,
|
||||
@@ -684,19 +840,46 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.tags || null,
|
||||
decrypted.pin ? 1 : 0,
|
||||
decrypted.authType,
|
||||
decrypted.forceKeyboardInteractive || null,
|
||||
decrypted.password || null,
|
||||
decrypted.key || null,
|
||||
decrypted.key_password || null,
|
||||
decrypted.keyPassword || null,
|
||||
decrypted.keyType || null,
|
||||
decrypted.sudoPassword || null,
|
||||
decrypted.autostartPassword || null,
|
||||
decrypted.autostartKey || null,
|
||||
decrypted.autostartKeyPassword || null,
|
||||
decrypted.credentialId || null,
|
||||
decrypted.overrideCredentialUsername ? 1 : 0,
|
||||
decrypted.enableTerminal ? 1 : 0,
|
||||
decrypted.enableTunnel ? 1 : 0,
|
||||
decrypted.tunnelConnections || null,
|
||||
decrypted.jumpHosts || null,
|
||||
decrypted.enableFileManager ? 1 : 0,
|
||||
decrypted.enableDocker ? 1 : 0,
|
||||
decrypted.showTerminalInSidebar ? 1 : 0,
|
||||
decrypted.showFileManagerInSidebar ? 1 : 0,
|
||||
decrypted.showTunnelInSidebar ? 1 : 0,
|
||||
decrypted.showDockerInSidebar ? 1 : 0,
|
||||
decrypted.showServerStatsInSidebar ? 1 : 0,
|
||||
decrypted.defaultPath || null,
|
||||
decrypted.statsConfig || null,
|
||||
decrypted.dockerConfig || null,
|
||||
decrypted.terminalConfig || null,
|
||||
decrypted.quickActions || null,
|
||||
decrypted.notes || null,
|
||||
decrypted.useSocks5 ? 1 : 0,
|
||||
decrypted.socks5Host || null,
|
||||
decrypted.socks5Port || null,
|
||||
decrypted.socks5Username || null,
|
||||
decrypted.socks5Password || null,
|
||||
decrypted.socks5ProxyChain || null,
|
||||
decrypted.domain || null,
|
||||
decrypted.security || null,
|
||||
decrypted.ignoreCert ? 1 : 0,
|
||||
decrypted.guacamoleConfig || null,
|
||||
decrypted.macAddress || null,
|
||||
decrypted.portKnockSequence || null,
|
||||
decrypted.createdAt,
|
||||
decrypted.updatedAt,
|
||||
);
|
||||
@@ -729,9 +912,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.username,
|
||||
decrypted.password || null,
|
||||
decrypted.key || null,
|
||||
decrypted.private_key || null,
|
||||
decrypted.public_key || null,
|
||||
decrypted.key_password || null,
|
||||
decrypted.privateKey || null,
|
||||
decrypted.publicKey || null,
|
||||
decrypted.keyPassword || null,
|
||||
decrypted.keyType || null,
|
||||
decrypted.detectedKeyType || null,
|
||||
decrypted.usageCount || 0,
|
||||
@@ -897,6 +1080,36 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/import:
|
||||
* post:
|
||||
* summary: Import user data
|
||||
* description: Imports user data from a SQLite database file.
|
||||
* tags:
|
||||
* - Database
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Incremental import completed successfully.
|
||||
* 400:
|
||||
* description: No file uploaded or password required for import.
|
||||
* 401:
|
||||
* description: Invalid password.
|
||||
* 500:
|
||||
* description: Failed to import SQLite data.
|
||||
*/
|
||||
app.post(
|
||||
"/database/import",
|
||||
authenticateJWT,
|
||||
@@ -908,7 +1121,6 @@ app.post(
|
||||
}
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
@@ -921,32 +1133,22 @@ app.post(
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const isOidcUser = !!userRecords[0].is_oidc;
|
||||
const isOidcUser = !!userRecords[0].isOidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -960,15 +1162,6 @@ app.post(
|
||||
});
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
}
|
||||
}
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -1030,13 +1223,13 @@ app.post(
|
||||
try {
|
||||
const existing = await mainDb
|
||||
.select()
|
||||
.from(sshData)
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.userId, userId),
|
||||
eq(sshData.ip, host.ip),
|
||||
eq(sshData.port, host.port),
|
||||
eq(sshData.username, host.username),
|
||||
eq(hosts.userId, userId),
|
||||
eq(hosts.ip, host.ip),
|
||||
eq(hosts.port, host.port),
|
||||
eq(hosts.username, host.username),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1055,19 +1248,45 @@ app.post(
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
autostartPassword: host.autostart_password,
|
||||
autostartKey: host.autostart_key,
|
||||
autostartKeyPassword: host.autostart_key_password,
|
||||
credentialId: null,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
enableTerminal: Boolean(host.enable_terminal),
|
||||
enableTunnel: Boolean(host.enable_tunnel),
|
||||
tunnelConnections: host.tunnel_connections,
|
||||
jumpHosts: host.jump_hosts,
|
||||
enableFileManager: Boolean(host.enable_file_manager),
|
||||
enableDocker: Boolean(host.enable_docker),
|
||||
showTerminalInSidebar: Boolean(host.show_terminal_in_sidebar),
|
||||
showFileManagerInSidebar: Boolean(
|
||||
host.show_file_manager_in_sidebar,
|
||||
),
|
||||
showTunnelInSidebar: Boolean(host.show_tunnel_in_sidebar),
|
||||
showDockerInSidebar: Boolean(host.show_docker_in_sidebar),
|
||||
showServerStatsInSidebar: Boolean(
|
||||
host.show_server_stats_in_sidebar,
|
||||
),
|
||||
defaultPath: host.default_path,
|
||||
statsConfig: host.stats_config,
|
||||
terminalConfig: host.terminal_config,
|
||||
quickActions: host.quick_actions,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -1078,7 +1297,7 @@ app.post(
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
await mainDb.insert(sshData).values(encrypted);
|
||||
await mainDb.insert(hosts).values(encrypted);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
@@ -1266,7 +1485,7 @@ app.post(
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (targetUser.length > 0 && targetUser[0].is_admin) {
|
||||
if (targetUser.length > 0 && targetUser[0].isAdmin) {
|
||||
try {
|
||||
const importedSettings = importDb
|
||||
.prepare("SELECT * FROM settings")
|
||||
@@ -1307,6 +1526,19 @@ app.post(
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
@@ -1361,6 +1593,31 @@ app.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/export/preview:
|
||||
* post:
|
||||
* summary: Preview user data export
|
||||
* description: Generates a preview of the user data export, including statistics about the data.
|
||||
* tags:
|
||||
* - Database
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* scope:
|
||||
* type: string
|
||||
* includeCredentials:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Export preview generated successfully.
|
||||
* 500:
|
||||
* description: Failed to generate export preview.
|
||||
*/
|
||||
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -1396,6 +1653,33 @@ app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/restore:
|
||||
* post:
|
||||
* summary: Restore database from backup
|
||||
* description: Restores the database from an encrypted backup file.
|
||||
* tags:
|
||||
* - Database
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* backupPath:
|
||||
* type: string
|
||||
* targetPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Database restored successfully.
|
||||
* 400:
|
||||
* description: Backup path is required or invalid encrypted backup file.
|
||||
* 500:
|
||||
* description: Database restore failed.
|
||||
*/
|
||||
app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { backupPath, targetPath } = req.body;
|
||||
@@ -1431,13 +1715,42 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
|
||||
});
|
||||
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/ssh", sshRoutes);
|
||||
app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
path.join(__dirname, "../../dist"),
|
||||
path.join(process.cwd(), "dist"),
|
||||
];
|
||||
|
||||
const frontendDist = frontendDistPaths.find((p) =>
|
||||
fs.existsSync(path.join(p, "index.html")),
|
||||
);
|
||||
|
||||
if (frontendDist) {
|
||||
databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
|
||||
operation: "static_files",
|
||||
});
|
||||
app.use(express.static(frontendDist));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === "GET" && req.accepts("html")) {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.use(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
@@ -1475,6 +1788,20 @@ async function initializeSecurity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/migration/status:
|
||||
* get:
|
||||
* summary: Get database migration status
|
||||
* description: Returns the status of the database migration.
|
||||
* tags:
|
||||
* - Database
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Migration status.
|
||||
* 500:
|
||||
* description: Failed to get migration status.
|
||||
*/
|
||||
app.get(
|
||||
"/database/migration/status",
|
||||
authenticateJWT,
|
||||
@@ -1498,13 +1825,17 @@ app.get(
|
||||
if (status.hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedSize = fs.statSync(dbPath).size;
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// expected - file may not exist
|
||||
}
|
||||
}
|
||||
|
||||
if (status.hasEncryptedDb) {
|
||||
try {
|
||||
encryptedSize = fs.statSync(encryptedDbPath).size;
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// expected - file may not exist
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1528,6 +1859,20 @@ app.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /database/migration/history:
|
||||
* get:
|
||||
* summary: Get database migration history
|
||||
* description: Returns the history of database migrations.
|
||||
* tags:
|
||||
* - Database
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Migration history.
|
||||
* 500:
|
||||
* description: Failed to get migration history.
|
||||
*/
|
||||
app.get(
|
||||
"/database/migration/history",
|
||||
authenticateJWT,
|
||||
@@ -1592,7 +1937,24 @@ app.get(
|
||||
},
|
||||
);
|
||||
|
||||
app.listen(HTTP_PORT, async () => {
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
`Port ${HTTP_PORT} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "http_server_port_conflict",
|
||||
port: HTTP_PORT,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
@@ -37,10 +37,11 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
} catch {
|
||||
// expected - sessions table may not exist yet
|
||||
}
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
@@ -182,6 +183,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -201,13 +214,21 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
notes TEXT,
|
||||
use_socks5 INTEGER,
|
||||
socks5_host TEXT,
|
||||
socks5_port INTEGER,
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
@@ -262,7 +283,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
@@ -328,12 +349,87 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear sessions on startup", {
|
||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
@@ -376,6 +472,42 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
@@ -486,11 +618,130 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_docker",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "security", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
|
||||
addColumnIfNotExists("ssh_data", "socks5_username", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "host_key_fingerprint", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "host_key_type", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "host_key_algorithm", "TEXT DEFAULT 'sha256'");
|
||||
addColumnIfNotExists("ssh_data", "host_key_first_seen", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"show_terminal_in_sidebar",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"show_file_manager_in_sidebar",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"show_tunnel_in_sidebar",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"show_docker_in_sidebar",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"show_server_stats_in_sidebar",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "system_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT");
|
||||
|
||||
try {
|
||||
const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}>;
|
||||
const usernameCol = tableInfo.find((col) => col.name === "username");
|
||||
|
||||
if (usernameCol && usernameCol.notnull === 1) {
|
||||
const tempTableName = "ssh_credentials_temp_migration";
|
||||
const allColumns = tableInfo.map((col) => col.name).join(", ");
|
||||
|
||||
sqlite.exec(`PRAGMA foreign_keys = OFF`);
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ${tempTableName} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
key_password TEXT,
|
||||
key_type TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
detected_key_type TEXT,
|
||||
system_password TEXT,
|
||||
system_key TEXT,
|
||||
system_key_password TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO ${tempTableName} SELECT ${allColumns} FROM ssh_credentials;
|
||||
|
||||
DROP TABLE ssh_credentials;
|
||||
|
||||
ALTER TABLE ${tempTableName} RENAME TO ssh_credentials;
|
||||
`);
|
||||
sqlite.exec(`PRAGMA foreign_keys = ON`);
|
||||
|
||||
databaseLogger.info("Successfully migrated ssh_credentials table to remove username NOT NULL constraint", {
|
||||
operation: "schema_migration_username_nullable",
|
||||
});
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate ssh_credentials username column", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
@@ -524,6 +775,34 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create snippet_access table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -551,12 +830,462 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM trusted_devices LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create trusted_devices table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM network_topology LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS network_topology (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
topology TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create network_topology table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM dashboard_preferences LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create dashboard_preferences table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create host_access table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add role_id column", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT");
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add sudo_password column", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sshDataMigrations: Array<{ column: string; sql: string }> = [
|
||||
{ column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" },
|
||||
{ column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" },
|
||||
{ column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
|
||||
{ column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
|
||||
{ column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" },
|
||||
{ column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
|
||||
{ column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
|
||||
{ column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" },
|
||||
{ column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" },
|
||||
{ column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
|
||||
{ column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
|
||||
{ column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
|
||||
{ column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" },
|
||||
{ column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
|
||||
{ column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
|
||||
{ column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
|
||||
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
try {
|
||||
sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(migration.sql);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add ${migration.column} column`, {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create roles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create user_roles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create audit_logs table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_recordings table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM shared_credentials LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS shared_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_access_id INTEGER NOT NULL,
|
||||
original_credential_id INTEGER NOT NULL,
|
||||
target_user_id TEXT NOT NULL,
|
||||
encrypted_username TEXT NOT NULL,
|
||||
encrypted_auth_type TEXT NOT NULL,
|
||||
encrypted_password TEXT,
|
||||
encrypted_key TEXT,
|
||||
encrypted_key_password TEXT,
|
||||
encrypted_key_type TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_re_encryption INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create shared_credentials table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS opkssh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
email TEXT,
|
||||
sub TEXT,
|
||||
issuer TEXT,
|
||||
audience TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, host_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create opkssh_tokens table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
try {
|
||||
const validSystemRoles = ['admin', 'user'];
|
||||
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
|
||||
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
|
||||
for (const roleName of unwantedRoleNames) {
|
||||
deleteByName.run(roleName);
|
||||
}
|
||||
|
||||
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
|
||||
for (const role of existingRoles) {
|
||||
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
|
||||
deleteOldSystemRole.run(role.name);
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to clean up old system roles", {
|
||||
operation: "schema_migration",
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
|
||||
const systemRoles = [
|
||||
{
|
||||
name: "admin",
|
||||
displayName: "rbac.roles.admin",
|
||||
description: "Administrator with full access",
|
||||
permissions: null,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
displayName: "rbac.roles.user",
|
||||
description: "Regular user",
|
||||
permissions: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const role of systemRoles) {
|
||||
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
||||
if (!existingRole) {
|
||||
try {
|
||||
sqlite.prepare(`
|
||||
INSERT INTO roles (name, display_name, description, is_system, permissions)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
`).run(role.name, role.displayName, role.description, role.permissions);
|
||||
} catch (insertError) {
|
||||
databaseLogger.warn(`Failed to create system role: ${role.name}`, {
|
||||
operation: "schema_migration",
|
||||
error: insertError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
|
||||
const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];
|
||||
|
||||
const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined;
|
||||
const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined;
|
||||
|
||||
if (adminRole) {
|
||||
const insertUserRole = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const admin of adminUsers) {
|
||||
try {
|
||||
insertUserRole.run(admin.id, adminRole.id);
|
||||
} catch {
|
||||
// Ignore duplicate errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userRole) {
|
||||
const insertUserRole = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const user of normalUsers) {
|
||||
try {
|
||||
insertUserRole.run(user.id, userRole.id);
|
||||
} catch {
|
||||
// Ignore duplicate errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate existing users to roles", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
} catch (seedError) {
|
||||
databaseLogger.warn("Failed to seed system roles", {
|
||||
operation: "schema_migration",
|
||||
error: seedError,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
};
|
||||
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
@@ -567,10 +1296,11 @@ async function saveMemoryDatabaseToFile() {
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
} catch {
|
||||
// expected - sessions table may not exist yet
|
||||
}
|
||||
|
||||
if (enableFileEncryption) {
|
||||
@@ -581,6 +1311,8 @@ async function saveMemoryDatabaseToFile() {
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.markClean();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
@@ -596,7 +1328,11 @@ async function handlePostInitFileEncryption() {
|
||||
if (memoryDatabase) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
setInterval(saveMemoryDatabaseToFile, 15 * 1000);
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
|
||||
}
|
||||
@@ -665,15 +1401,18 @@ async function cleanupDatabase() {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {
|
||||
// expected - file cleanup best effort
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {
|
||||
// expected - dir cleanup best effort
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected - temp dir cleanup best effort
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +1421,7 @@ process.on("exit", () => {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {
|
||||
// expected - database may already be closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,25 +4,25 @@ 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),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: 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"),
|
||||
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
authorizationUrl: text("authorization_url"),
|
||||
tokenUrl: text("token_url"),
|
||||
identifierPath: text("identifier_path"),
|
||||
namePath: text("name_path"),
|
||||
scopes: text().default("openid email profile"),
|
||||
|
||||
totp_secret: text("totp_secret"),
|
||||
totp_enabled: integer("totp_enabled", { mode: "boolean" })
|
||||
totpSecret: text("totp_secret"),
|
||||
totpEnabled: integer("totp_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
totp_backup_codes: text("totp_backup_codes"),
|
||||
totpBackupCodes: text("totp_backup_codes"),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
@@ -47,11 +47,29 @@ export const sessions = sqliteTable("sessions", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
export const trustedDevices = sqliteTable("trusted_devices", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
deviceFingerprint: text("device_fingerprint").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsedAt: text("last_used_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hosts = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
connectionType: text("connection_type").notNull().default("ssh"),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
@@ -64,8 +82,9 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
key_password: text("key_password"),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
sudoPassword: text("sudo_password"),
|
||||
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
@@ -86,10 +105,52 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
showFileManagerInSidebar: integer("show_file_manager_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTunnelInSidebar: integer("show_tunnel_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showDockerInSidebar: integer("show_docker_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showServerStatsInSidebar: integer("show_server_stats_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
dockerConfig: text("docker_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
|
||||
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
||||
socks5Host: text("socks5_host"),
|
||||
socks5Port: integer("socks5_port"),
|
||||
socks5Username: text("socks5_username"),
|
||||
socks5Password: text("socks5_password"),
|
||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||
|
||||
macAddress: text("mac_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
|
||||
hostKeyFingerprint: text("host_key_fingerprint"),
|
||||
hostKeyType: text("host_key_type"),
|
||||
hostKeyAlgorithm: text("host_key_algorithm").default("sha256"),
|
||||
hostKeyFirstSeen: text("host_key_first_seen"),
|
||||
hostKeyLastVerified: text("host_key_last_verified"),
|
||||
hostKeyChangedCount: integer("host_key_changed_count").default(0),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -105,7 +166,7 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
lastOpened: text("last_opened")
|
||||
@@ -120,7 +181,7 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
pinnedAt: text("pinned_at")
|
||||
@@ -135,7 +196,7 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
path: text("path").notNull(),
|
||||
createdAt: text("created_at")
|
||||
@@ -164,14 +225,19 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
authType: text("auth_type").notNull(),
|
||||
username: text("username").notNull(),
|
||||
username: text("username"),
|
||||
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"),
|
||||
privateKey: text("private_key", { length: 16384 }),
|
||||
publicKey: text("public_key", { length: 4096 }),
|
||||
keyPassword: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
detectedKeyType: text("detected_key_type"),
|
||||
|
||||
systemPassword: text("system_password"),
|
||||
systemKey: text("system_key", { length: 16384 }),
|
||||
systemKeyPassword: text("system_key_password"),
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
createdAt: text("created_at")
|
||||
@@ -189,7 +255,7 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -232,6 +298,30 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetAccess = sqliteTable("snippet_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
snippetId: integer("snippet_id")
|
||||
.notNull()
|
||||
.references(() => snippets.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("view"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -256,7 +346,7 @@ export const recentActivity = sqliteTable("recent_activity", {
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
hostName: text("host_name"),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
@@ -270,9 +360,215 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
command: text("command").notNull(),
|
||||
executedAt: text("executed_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const networkTopology = sqliteTable("network_topology", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
topology: text("topology"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
layout: text("layout").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id")
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("view"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastAccessedAt: text("last_accessed_at"),
|
||||
accessCount: integer("access_count").notNull().default(0),
|
||||
});
|
||||
|
||||
export const sharedCredentials = sqliteTable("shared_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
hostAccessId: integer("host_access_id")
|
||||
.notNull()
|
||||
.references(() => hostAccess.id, { onDelete: "cascade" }),
|
||||
|
||||
originalCredentialId: integer("original_credential_id")
|
||||
.notNull()
|
||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||
|
||||
targetUserId: text("target_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
encryptedUsername: text("encrypted_username").notNull(),
|
||||
encryptedAuthType: text("encrypted_auth_type").notNull(),
|
||||
encryptedPassword: text("encrypted_password"),
|
||||
encryptedKey: text("encrypted_key", { length: 16384 }),
|
||||
encryptedKeyPassword: text("encrypted_key_password"),
|
||||
encryptedKeyType: text("encrypted_key_type"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const roles = sqliteTable("roles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
displayName: text("display_name").notNull(),
|
||||
description: text("description"),
|
||||
|
||||
isSystem: integer("is_system", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
permissions: text("permissions"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const userRoles = sqliteTable("user_roles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id")
|
||||
.notNull()
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
|
||||
grantedBy: text("granted_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
grantedAt: text("granted_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
username: text("username").notNull(),
|
||||
|
||||
action: text("action").notNull(),
|
||||
resourceType: text("resource_type").notNull(),
|
||||
resourceId: text("resource_id"),
|
||||
resourceName: text("resource_name"),
|
||||
|
||||
details: text("details"),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
|
||||
success: integer("success", { mode: "boolean" }).notNull(),
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessId: integer("access_id").references(() => hostAccess.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
duration: integer("duration"),
|
||||
|
||||
commands: text("commands"),
|
||||
dangerousActions: text("dangerous_actions"),
|
||||
|
||||
recordingPath: text("recording_path"),
|
||||
|
||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||
.default(false),
|
||||
terminationReason: text("termination_reason"),
|
||||
});
|
||||
|
||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
|
||||
email: text("email"),
|
||||
sub: text("sub"),
|
||||
issuer: text("issuer"),
|
||||
audience: text("audience"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
@@ -60,6 +61,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "TermixAlertChecker/1.0",
|
||||
},
|
||||
agent: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -99,8 +101,20 @@ const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
// Route: Get alerts for the authenticated user (excluding dismissed ones)
|
||||
// GET /alerts
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts:
|
||||
* get:
|
||||
* summary: Get active alerts
|
||||
* description: Fetches active alerts for the authenticated user, excluding those that have been dismissed.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of active alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch alerts.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -131,8 +145,33 @@ router.get("/", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Dismiss an alert for the authenticated user
|
||||
// POST /alerts/dismiss
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* post:
|
||||
* summary: Dismiss an alert
|
||||
* description: Marks an alert as dismissed for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert dismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 409:
|
||||
* description: Alert already dismissed.
|
||||
* 500:
|
||||
* description: Failed to dismiss alert.
|
||||
*/
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
@@ -170,8 +209,20 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Get dismissed alerts for a user
|
||||
// GET /alerts/dismissed/:userId
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismissed:
|
||||
* get:
|
||||
* summary: Get dismissed alerts
|
||||
* description: Fetches a list of alerts that have been dismissed by the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of dismissed alerts.
|
||||
* 500:
|
||||
* description: Failed to fetch dismissed alerts.
|
||||
*/
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -194,8 +245,33 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route: Undismiss an alert for the authenticated user (remove from dismissed list)
|
||||
// DELETE /alerts/dismiss
|
||||
/**
|
||||
* @openapi
|
||||
* /alerts/dismiss:
|
||||
* delete:
|
||||
* summary: Undismiss an alert
|
||||
* description: Removes an alert from the dismissed list for the authenticated user.
|
||||
* tags:
|
||||
* - Alerts
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* alertId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Alert undismissed successfully.
|
||||
* 400:
|
||||
* description: Alert ID is required.
|
||||
* 404:
|
||||
* description: Dismissed alert not found.
|
||||
* 500:
|
||||
* description: Failed to undismiss alert.
|
||||
*/
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CredentialBackend,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import {
|
||||
sshCredentials,
|
||||
sshCredentialUsage,
|
||||
hosts,
|
||||
hostAccess,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -76,8 +84,52 @@ const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Create a new credential
|
||||
// POST /credentials
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials:
|
||||
* post:
|
||||
* summary: Create a new credential
|
||||
* description: Creates a new SSH credential for the authenticated user.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* folder:
|
||||
* type: string
|
||||
* tags:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* authType:
|
||||
* type: string
|
||||
* enum: [password, key]
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* key:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* keyType:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Credential created successfully.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
* 500:
|
||||
* description: Failed to create credential.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
@@ -97,18 +149,13 @@ router.post(
|
||||
keyType,
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(name) ||
|
||||
!isNonEmptyString(username)
|
||||
) {
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
authLogger.warn("Invalid credential creation data validation failed", {
|
||||
operation: "credential_create",
|
||||
userId,
|
||||
hasName: !!name,
|
||||
hasUsername: !!username,
|
||||
});
|
||||
return res.status(400).json({ error: "Name and username are required" });
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
if (!["password", "key"].includes(authType)) {
|
||||
@@ -175,12 +222,12 @@ router.post(
|
||||
folder: folder?.trim() || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
authType,
|
||||
username: username.trim(),
|
||||
username: username?.trim() || null,
|
||||
password: plainPassword,
|
||||
key: plainKey,
|
||||
private_key: keyInfo?.privateKey || plainKey,
|
||||
public_key: keyInfo?.publicKey || null,
|
||||
key_password: plainKeyPassword,
|
||||
privateKey: keyInfo?.privateKey || plainKey,
|
||||
publicKey: keyInfo?.publicKey || null,
|
||||
keyPassword: plainKeyPassword,
|
||||
keyType: keyType || null,
|
||||
detectedKeyType: keyInfo?.keyType || null,
|
||||
usageCount: 0,
|
||||
@@ -223,8 +270,22 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Get all credentials for the authenticated user
|
||||
// GET /credentials
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials:
|
||||
* get:
|
||||
* summary: Get all credentials
|
||||
* description: Retrieves all SSH credentials for the authenticated user.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of credentials.
|
||||
* 400:
|
||||
* description: Invalid userId.
|
||||
* 500:
|
||||
* description: Failed to fetch credentials.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
@@ -256,8 +317,22 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get all unique credential folders for the authenticated user
|
||||
// GET /credentials/folders
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/folders:
|
||||
* get:
|
||||
* summary: Get credential folders
|
||||
* description: Retrieves all unique credential folders for the authenticated user.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of folder names.
|
||||
* 400:
|
||||
* description: Invalid userId.
|
||||
* 500:
|
||||
* description: Failed to fetch credential folders.
|
||||
*/
|
||||
router.get(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
@@ -294,15 +369,37 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific credential by ID (with plain text secrets)
|
||||
// GET /credentials/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
* get:
|
||||
* summary: Get a specific credential
|
||||
* description: Retrieves a specific credential by its ID, including secrets.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested credential.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to fetch credential.
|
||||
*/
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential fetch");
|
||||
@@ -337,14 +434,14 @@ router.get(
|
||||
if (credential.key) {
|
||||
output.key = credential.key;
|
||||
}
|
||||
if (credential.private_key) {
|
||||
output.privateKey = credential.private_key;
|
||||
if (credential.privateKey) {
|
||||
output.privateKey = credential.privateKey;
|
||||
}
|
||||
if (credential.public_key) {
|
||||
output.publicKey = credential.public_key;
|
||||
if (credential.publicKey) {
|
||||
output.publicKey = credential.publicKey;
|
||||
}
|
||||
if (credential.key_password) {
|
||||
output.keyPassword = credential.key_password;
|
||||
if (credential.keyPassword) {
|
||||
output.keyPassword = credential.keyPassword;
|
||||
}
|
||||
|
||||
res.json(output);
|
||||
@@ -358,21 +455,60 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Update a credential
|
||||
// PUT /credentials/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
* put:
|
||||
* summary: Update a credential
|
||||
* description: Updates a specific credential by its ID.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated credential.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to update credential.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential update");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
authLogger.info("Updating SSH credential", {
|
||||
operation: "credential_update",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
changes: Object.keys(updateData),
|
||||
});
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -403,7 +539,7 @@ router.put(
|
||||
: updateData.tags || "";
|
||||
}
|
||||
if (updateData.username !== undefined)
|
||||
updateFields.username = updateData.username.trim();
|
||||
updateFields.username = updateData.username?.trim() || null;
|
||||
if (updateData.authType !== undefined)
|
||||
updateFields.authType = updateData.authType;
|
||||
if (updateData.keyType !== undefined)
|
||||
@@ -428,13 +564,13 @@ router.put(
|
||||
error: `Invalid SSH key: ${keyInfo.error}`,
|
||||
});
|
||||
}
|
||||
updateFields.private_key = keyInfo.privateKey;
|
||||
updateFields.public_key = keyInfo.publicKey;
|
||||
updateFields.privateKey = keyInfo.privateKey;
|
||||
updateFields.publicKey = keyInfo.publicKey;
|
||||
updateFields.detectedKeyType = keyInfo.keyType;
|
||||
}
|
||||
}
|
||||
if (updateData.keyPassword !== undefined) {
|
||||
updateFields.key_password = updateData.keyPassword || null;
|
||||
updateFields.keyPassword = updateData.keyPassword || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
@@ -470,19 +606,20 @@ router.put(
|
||||
userId,
|
||||
);
|
||||
|
||||
const credential = updated[0];
|
||||
authLogger.success(
|
||||
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_update_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
name: credential.name,
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
},
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||
parseInt(id),
|
||||
userId,
|
||||
);
|
||||
|
||||
authLogger.success("SSH credential updated", {
|
||||
operation: "credential_update_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
});
|
||||
|
||||
res.json(formatCredentialOutput(updated[0]));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update credential", err);
|
||||
@@ -494,20 +631,47 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a credential
|
||||
// DELETE /credentials/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
* delete:
|
||||
* summary: Delete a credential
|
||||
* description: Deletes a specific credential by its ID.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Credential deleted successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to delete credential.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential deletion");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
authLogger.info("Deleting SSH credential", {
|
||||
operation: "credential_delete",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
});
|
||||
|
||||
try {
|
||||
const credentialToDelete = await db
|
||||
@@ -524,38 +688,52 @@ router.delete(
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
// Update hosts using this credential to set credentialId to null
|
||||
// This prevents orphaned references before deletion
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.credentialId, parseInt(id)),
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
|
||||
);
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
await db
|
||||
.update(sshData)
|
||||
.update(hosts)
|
||||
.set({
|
||||
credentialId: null,
|
||||
password: null,
|
||||
key: null,
|
||||
key_password: null,
|
||||
keyPassword: null,
|
||||
authType: "password",
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.credentialId, parseInt(id)),
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
|
||||
);
|
||||
|
||||
for (const host of hostsUsingCredential) {
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, host.id))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (revokedShares.length > 0) {
|
||||
authLogger.info(
|
||||
"Auto-revoked host shares due to credential deletion",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: host.id,
|
||||
credentialId: parseInt(id),
|
||||
revokedCount: revokedShares.length,
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
|
||||
// No need for manual deletion
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
|
||||
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
@@ -566,18 +744,11 @@ router.delete(
|
||||
),
|
||||
);
|
||||
|
||||
const credential = credentialToDelete[0];
|
||||
authLogger.success(
|
||||
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_delete_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
name: credential.name,
|
||||
authType: credential.authType,
|
||||
username: credential.username,
|
||||
},
|
||||
);
|
||||
authLogger.success("SSH credential deleted", {
|
||||
operation: "credential_delete_success",
|
||||
userId,
|
||||
credentialId: parseInt(id),
|
||||
});
|
||||
|
||||
res.json({ message: "Credential deleted successfully" });
|
||||
} catch (err) {
|
||||
@@ -590,14 +761,46 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Apply a credential to an SSH host (for quick application)
|
||||
// POST /credentials/:id/apply-to-host/:hostId
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/apply-to-host/{hostId}:
|
||||
* post:
|
||||
* summary: Apply a credential to a host
|
||||
* description: Applies a credential to an SSH host for quick application.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Credential applied to host successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to apply credential to host.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/apply-to-host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id: credentialId, hostId } = req.params;
|
||||
const credentialId = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const hostId = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||
authLogger.warn("Invalid request for credential application");
|
||||
@@ -626,20 +829,18 @@ router.post(
|
||||
const credential = credentials[0];
|
||||
|
||||
await db
|
||||
.update(sshData)
|
||||
.update(hosts)
|
||||
.set({
|
||||
credentialId: parseInt(credentialId),
|
||||
username: credential.username as string,
|
||||
authType: (credential.auth_type || credential.authType) as string,
|
||||
username: (credential.username as string) || "",
|
||||
authType: credential.authType as string,
|
||||
password: null,
|
||||
key: null,
|
||||
key_password: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||
);
|
||||
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId)));
|
||||
|
||||
await db.insert(sshCredentialUsage).values({
|
||||
credentialId: parseInt(credentialId),
|
||||
@@ -669,14 +870,36 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Get hosts using a specific credential
|
||||
// GET /credentials/:id/hosts
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/hosts:
|
||||
* get:
|
||||
* summary: Get hosts using a credential
|
||||
* description: Retrieves a list of hosts that are using a specific credential.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of hosts.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to fetch hosts using credential.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id: credentialId } = req.params;
|
||||
const credentialId = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId) {
|
||||
authLogger.warn("Invalid request for credential hosts fetch");
|
||||
@@ -684,17 +907,17 @@ router.get(
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await db
|
||||
const hostsUsingCredential = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.credentialId, parseInt(credentialId)),
|
||||
eq(sshData.userId, userId),
|
||||
eq(hosts.credentialId, parseInt(credentialId)),
|
||||
eq(hosts.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
res.json(hosts.map((host) => formatSSHHostOutput(host)));
|
||||
res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host)));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch hosts using credential", err);
|
||||
res.status(500).json({
|
||||
@@ -721,15 +944,15 @@ function formatCredentialOutput(
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
authType: credential.authType || credential.auth_type,
|
||||
username: credential.username,
|
||||
publicKey: credential.public_key || credential.publicKey,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
|
||||
usageCount: credential.usage_count || credential.usageCount || 0,
|
||||
lastUsed: credential.last_used || credential.lastUsed,
|
||||
createdAt: credential.created_at || credential.createdAt,
|
||||
updatedAt: credential.updated_at || credential.updatedAt,
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
publicKey: credential.publicKey,
|
||||
keyType: credential.keyType,
|
||||
detectedKeyType: credential.detectedKeyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -764,8 +987,33 @@ function formatSSHHostOutput(
|
||||
};
|
||||
}
|
||||
|
||||
// Rename a credential folder
|
||||
// PUT /credentials/folders/rename
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/folders/rename:
|
||||
* put:
|
||||
* summary: Rename a credential folder
|
||||
* description: Renames a credential folder for the authenticated user.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oldName:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder renamed successfully.
|
||||
* 400:
|
||||
* description: Both oldName and newName are required.
|
||||
* 500:
|
||||
* description: Failed to rename folder.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
@@ -804,8 +1052,33 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Detect SSH key type endpoint
|
||||
// POST /credentials/detect-key-type
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/detect-key-type:
|
||||
* post:
|
||||
* summary: Detect SSH key type
|
||||
* description: Detects the type of an SSH private key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key type detection result.
|
||||
* 400:
|
||||
* description: Private key is required.
|
||||
* 500:
|
||||
* description: Failed to detect key type.
|
||||
*/
|
||||
router.post(
|
||||
"/detect-key-type",
|
||||
authenticateJWT,
|
||||
@@ -838,8 +1111,31 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Detect SSH public key type endpoint
|
||||
// POST /credentials/detect-public-key-type
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/detect-public-key-type:
|
||||
* post:
|
||||
* summary: Detect SSH public key type
|
||||
* description: Detects the type of an SSH public key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* publicKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key type detection result.
|
||||
* 400:
|
||||
* description: Public key is required.
|
||||
* 500:
|
||||
* description: Failed to detect public key type.
|
||||
*/
|
||||
router.post(
|
||||
"/detect-public-key-type",
|
||||
authenticateJWT,
|
||||
@@ -873,8 +1169,35 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Validate SSH key pair endpoint
|
||||
// POST /credentials/validate-key-pair
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/validate-key-pair:
|
||||
* post:
|
||||
* summary: Validate SSH key pair
|
||||
* description: Validates if a given SSH private key and public key match.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* publicKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key pair validation result.
|
||||
* 400:
|
||||
* description: Private key and public key are required.
|
||||
* 500:
|
||||
* description: Failed to validate key pair.
|
||||
*/
|
||||
router.post(
|
||||
"/validate-key-pair",
|
||||
authenticateJWT,
|
||||
@@ -917,8 +1240,32 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Generate new SSH key pair endpoint
|
||||
// POST /credentials/generate-key-pair
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/generate-key-pair:
|
||||
* post:
|
||||
* summary: Generate new SSH key pair
|
||||
* description: Generates a new SSH key pair.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* keyType:
|
||||
* type: string
|
||||
* keySize:
|
||||
* type: integer
|
||||
* passphrase:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new key pair.
|
||||
* 500:
|
||||
* description: Failed to generate SSH key pair.
|
||||
*/
|
||||
router.post(
|
||||
"/generate-key-pair",
|
||||
authenticateJWT,
|
||||
@@ -960,8 +1307,33 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Generate public key from private key endpoint
|
||||
// POST /credentials/generate-public-key
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/generate-public-key:
|
||||
* post:
|
||||
* summary: Generate public key from private key
|
||||
* description: Generates a public key from a given private key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The generated public key.
|
||||
* 400:
|
||||
* description: Private key is required.
|
||||
* 500:
|
||||
* description: Failed to generate public key.
|
||||
*/
|
||||
router.post(
|
||||
"/generate-public-key",
|
||||
authenticateJWT,
|
||||
@@ -1124,10 +1496,9 @@ router.post(
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_credentialData: Record<string, unknown>,
|
||||
credData: CredentialBackend,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const publicKey = credData.publicKey as string;
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
|
||||
@@ -1248,7 +1619,7 @@ async function deploySSHKeyToHost(
|
||||
.replace(/'/g, "'\\''");
|
||||
|
||||
conn.exec(
|
||||
`printf '%s\\n' '${escapedKey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
`printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(addTimeout);
|
||||
@@ -1467,13 +1838,47 @@ async function deploySSHKeyToHost(
|
||||
});
|
||||
}
|
||||
|
||||
// Deploy SSH Key to Host endpoint
|
||||
// POST /credentials/:id/deploy-to-host
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/deploy-to-host:
|
||||
* post:
|
||||
* summary: Deploy SSH key to a host
|
||||
* description: Deploys an SSH public key to a target host's authorized_keys file.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* targetHostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: SSH key deployed successfully.
|
||||
* 400:
|
||||
* description: Credential ID and target host ID are required.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 404:
|
||||
* description: Credential or target host not found.
|
||||
* 500:
|
||||
* description: Failed to deploy SSH key.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/deploy-to-host",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const credentialId = parseInt(req.params.id);
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const credentialId = parseInt(id);
|
||||
const { targetHostId } = req.body;
|
||||
|
||||
if (!credentialId || !targetHostId) {
|
||||
@@ -1510,7 +1915,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const credData = credential[0];
|
||||
const credData = credential[0] as unknown as CredentialBackend;
|
||||
|
||||
if (credData.authType !== "key") {
|
||||
return res.status(400).json({
|
||||
@@ -1519,7 +1924,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const publicKey = credData.public_key || credData.publicKey;
|
||||
const publicKey = credData.publicKey;
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -1527,7 +1932,7 @@ router.post(
|
||||
});
|
||||
}
|
||||
const targetHost = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.id, targetHostId)).limit(1),
|
||||
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -1575,15 +1980,14 @@ router.post(
|
||||
if (hostCredential && hostCredential.length > 0) {
|
||||
const cred = hostCredential[0];
|
||||
|
||||
hostConfig.authType = cred.auth_type || cred.authType;
|
||||
hostConfig.authType = cred.authType;
|
||||
hostConfig.username = cred.username;
|
||||
|
||||
if ((cred.auth_type || cred.authType) === "password") {
|
||||
if (cred.authType === "password") {
|
||||
hostConfig.password = cred.password;
|
||||
} else if ((cred.auth_type || cred.authType) === "key") {
|
||||
hostConfig.privateKey =
|
||||
cred.private_key || cred.privateKey || cred.key;
|
||||
hostConfig.keyPassword = cred.key_password || cred.keyPassword;
|
||||
} else if (cred.authType === "key") {
|
||||
hostConfig.privateKey = cred.privateKey || cred.key;
|
||||
hostConfig.keyPassword = cred.keyPassword;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
@@ -1599,11 +2003,7 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
publicKey as string,
|
||||
credData,
|
||||
);
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import express from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { networkTopology } from "../db/schema.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* get:
|
||||
* summary: Get network topology for authenticated user
|
||||
* description: Retrieves the saved network topology graph (nodes and edges) for the current user. Returns null if no topology exists.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Network topology retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Node data including id, label, type, etc.
|
||||
* position:
|
||||
* type: object
|
||||
* description: Node position coordinates
|
||||
* properties:
|
||||
* x:
|
||||
* type: number
|
||||
* y:
|
||||
* type: number
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* data:
|
||||
* type: object
|
||||
* description: Edge data including source and target node ids
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const result = await db
|
||||
.select()
|
||||
.from(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
|
||||
if (result.length > 0) {
|
||||
const topologyStr = result[0].topology;
|
||||
const topology = topologyStr ? JSON.parse(topologyStr) : null;
|
||||
return res.json(topology);
|
||||
} else {
|
||||
return res.json(null);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch network topology", error, {
|
||||
operation: "network_topology_fetch",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to fetch network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /network-topology:
|
||||
* post:
|
||||
* summary: Save network topology for authenticated user
|
||||
* description: Saves or updates the network topology graph. Uses upsert logic - creates new record if none exists, updates existing record otherwise.
|
||||
* tags:
|
||||
* - Network Topology
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - topology
|
||||
* properties:
|
||||
* topology:
|
||||
* type: object
|
||||
* description: Network topology data containing nodes and edges
|
||||
* properties:
|
||||
* nodes:
|
||||
* type: array
|
||||
* description: Array of graph nodes (hosts and groups)
|
||||
* edges:
|
||||
* type: array
|
||||
* description: Array of graph edges (connections)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Topology saved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Invalid topology data
|
||||
* 401:
|
||||
* description: User not authenticated
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const { topology } = req.body;
|
||||
if (!topology) {
|
||||
return res.status(400).json({ error: "Topology data is required" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const topologyStr =
|
||||
typeof topology === "string" ? topology : JSON.stringify(topology);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(networkTopology)
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(networkTopology)
|
||||
.set({ topology: topologyStr })
|
||||
.where(eq(networkTopology.userId, userId));
|
||||
} else {
|
||||
await db
|
||||
.insert(networkTopology)
|
||||
.values({ userId, topology: topologyStr });
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save network topology", error, {
|
||||
operation: "network_topology_save",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: "Failed to save network topology",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
type SnippetReorderRequestBody = {
|
||||
snippets?: unknown;
|
||||
updates?: unknown;
|
||||
};
|
||||
|
||||
export function extractSnippetReorderUpdates(
|
||||
body: unknown,
|
||||
): SnippetReorderUpdate[] | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = body as SnippetReorderRequestBody;
|
||||
// Keep accepting the legacy `updates` key so older clients do not break
|
||||
// while the web and desktop helpers converge on `snippets`.
|
||||
const snippetsUpdates = Array.isArray(payload.snippets)
|
||||
? payload.snippets
|
||||
: Array.isArray(payload.updates)
|
||||
? payload.updates
|
||||
: null;
|
||||
|
||||
if (!snippetsUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippetsUpdates as SnippetReorderUpdate[];
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { db } from "../db/index.js";
|
||||
import { snippets, snippetFolders } from "../db/schema.js";
|
||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -17,8 +19,22 @@ const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Get all snippet folders
|
||||
// GET /snippets/folders
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/folders:
|
||||
* get:
|
||||
* summary: Get all snippet folders
|
||||
* description: Retrieves all snippet folders for the authenticated user.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of snippet folders.
|
||||
* 400:
|
||||
* description: Invalid userId.
|
||||
* 500:
|
||||
* description: Failed to fetch snippet folders.
|
||||
*/
|
||||
router.get(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
@@ -46,8 +62,37 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new snippet folder
|
||||
// POST /snippets/folders
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/folders:
|
||||
* post:
|
||||
* summary: Create a new snippet folder
|
||||
* description: Creates a new snippet folder for the authenticated user.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Snippet folder created successfully.
|
||||
* 400:
|
||||
* description: Folder name is required.
|
||||
* 409:
|
||||
* description: Folder with this name already exists.
|
||||
* 500:
|
||||
* description: Failed to create snippet folder.
|
||||
*/
|
||||
router.post(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
@@ -110,15 +155,50 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Update snippet folder metadata (color, icon)
|
||||
// PUT /snippets/folders/:name/metadata
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/folders/{name}/metadata:
|
||||
* put:
|
||||
* summary: Update snippet folder metadata
|
||||
* description: Updates the metadata (color, icon) of a snippet folder.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: name
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippet folder metadata updated successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Folder not found.
|
||||
* 500:
|
||||
* description: Failed to update snippet folder metadata.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/:name/metadata",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name } = req.params;
|
||||
const name = Array.isArray(req.params.name)
|
||||
? req.params.name[0]
|
||||
: req.params.name;
|
||||
const { color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
@@ -194,8 +274,37 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Rename snippet folder
|
||||
// PUT /snippets/folders/rename
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/folders/rename:
|
||||
* put:
|
||||
* summary: Rename a snippet folder
|
||||
* description: Renames a snippet folder for the authenticated user.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oldName:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder renamed successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Folder not found.
|
||||
* 409:
|
||||
* description: Folder with new name already exists.
|
||||
* 500:
|
||||
* description: Failed to rename snippet folder.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
@@ -282,15 +391,37 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Delete snippet folder
|
||||
// DELETE /snippets/folders/:name
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/folders/{name}:
|
||||
* delete:
|
||||
* summary: Delete a snippet folder
|
||||
* description: Deletes a snippet folder and moves its snippets to the root.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: name
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippet folder deleted successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to delete snippet folder.
|
||||
*/
|
||||
router.delete(
|
||||
"/folders/:name",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name } = req.params;
|
||||
const name = Array.isArray(req.params.name)
|
||||
? req.params.name[0]
|
||||
: req.params.name;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
authLogger.warn("Invalid request for snippet folder delete");
|
||||
@@ -338,22 +469,55 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
// Reorder snippets (bulk update)
|
||||
// PUT /snippets/reorder
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/reorder:
|
||||
* put:
|
||||
* summary: Reorder snippets
|
||||
* description: Bulk updates the order and folder of snippets. Accepts
|
||||
* `snippets` and the legacy `updates` payload key.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* snippets:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* order:
|
||||
* type: integer
|
||||
* folder:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippets reordered successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to reorder snippets.
|
||||
*/
|
||||
router.put(
|
||||
"/reorder",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippets: snippetUpdates } = req.body;
|
||||
const snippetUpdates = extractSnippetReorderUpdates(req.body);
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippet reorder");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
|
||||
if (!snippetUpdates || snippetUpdates.length === 0) {
|
||||
authLogger.warn("Invalid snippet reorder data", {
|
||||
operation: "snippet_reorder",
|
||||
userId,
|
||||
@@ -405,8 +569,35 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Execute a snippet on a host
|
||||
// POST /snippets/execute
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/execute:
|
||||
* post:
|
||||
* summary: Execute a snippet on a host
|
||||
* description: Executes a snippet on a specified host.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* snippetId:
|
||||
* type: integer
|
||||
* hostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippet executed successfully.
|
||||
* 400:
|
||||
* description: Snippet ID and Host ID are required.
|
||||
* 404:
|
||||
* description: Snippet or host not found.
|
||||
* 500:
|
||||
* description: Failed to execute snippet.
|
||||
*/
|
||||
router.post(
|
||||
"/execute",
|
||||
authenticateJWT,
|
||||
@@ -444,17 +635,15 @@ router.post(
|
||||
const snippet = snippetResult[0];
|
||||
|
||||
const { Client } = await import("ssh2");
|
||||
const { sshData, sshCredentials } = await import("../db/schema.js");
|
||||
const { hosts, sshCredentials } = await import("../db/schema.js");
|
||||
|
||||
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
||||
|
||||
const hostResult = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
||||
),
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -467,7 +656,7 @@ router.post(
|
||||
|
||||
let password = host.password;
|
||||
let privateKey = host.key;
|
||||
let passphrase = host.key_password;
|
||||
let passphrase = host.keyPassword;
|
||||
let authType = host.authType;
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -487,12 +676,12 @@ router.post(
|
||||
|
||||
if (credResult.length > 0) {
|
||||
const cred = credResult[0];
|
||||
authType = (cred.auth_type || cred.authType || authType) as string;
|
||||
authType = (cred.authType || authType) as string;
|
||||
password = (cred.password || undefined) as string | undefined;
|
||||
privateKey = (cred.private_key || cred.key || undefined) as
|
||||
privateKey = (cred.privateKey || cred.key || undefined) as
|
||||
| string
|
||||
| undefined;
|
||||
passphrase = (cred.key_password || undefined) as string | undefined;
|
||||
passphrase = (cred.keyPassword || undefined) as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +732,7 @@ router.post(
|
||||
reject(err);
|
||||
});
|
||||
|
||||
const config: any = {
|
||||
const config: Record<string, unknown> = {
|
||||
host: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
@@ -589,18 +778,7 @@ router.post(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -662,8 +840,22 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Get all snippets for the authenticated user
|
||||
// GET /snippets
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets:
|
||||
* get:
|
||||
* summary: Get all snippets
|
||||
* description: Retrieves all snippets for the authenticated user.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of snippets.
|
||||
* 400:
|
||||
* description: Invalid userId.
|
||||
* 500:
|
||||
* description: Failed to fetch snippets.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
@@ -696,15 +888,37 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific snippet by ID
|
||||
// GET /snippets/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/{id}:
|
||||
* get:
|
||||
* summary: Get a specific snippet
|
||||
* description: Retrieves a specific snippet by its ID.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested snippet.
|
||||
* 400:
|
||||
* description: Invalid request parameters.
|
||||
* 404:
|
||||
* description: Snippet not found.
|
||||
* 500:
|
||||
* description: Failed to fetch snippet.
|
||||
*/
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
|
||||
@@ -735,8 +949,39 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new snippet
|
||||
// POST /snippets
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets:
|
||||
* post:
|
||||
* summary: Create a new snippet
|
||||
* description: Creates a new snippet for the authenticated user.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* content:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* folder:
|
||||
* type: string
|
||||
* order:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Snippet created successfully.
|
||||
* 400:
|
||||
* description: Name and content are required.
|
||||
* 500:
|
||||
* description: Failed to create snippet.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
@@ -788,9 +1033,8 @@ router.post(
|
||||
};
|
||||
|
||||
const result = await db.insert(snippets).values(insertData).returning();
|
||||
|
||||
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
|
||||
operation: "snippet_create_success",
|
||||
databaseLogger.info("Command snippet created", {
|
||||
operation: "snippet_create",
|
||||
userId,
|
||||
snippetId: result[0].id,
|
||||
name,
|
||||
@@ -806,15 +1050,54 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Update a snippet
|
||||
// PUT /snippets/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/{id}:
|
||||
* put:
|
||||
* summary: Update a snippet
|
||||
* description: Updates a specific snippet by its ID.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* content:
|
||||
* type: string
|
||||
* description:
|
||||
* type: string
|
||||
* folder:
|
||||
* type: string
|
||||
* order:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated snippet.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Snippet not found.
|
||||
* 500:
|
||||
* description: Failed to update snippet.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
@@ -862,16 +1145,11 @@ router.put(
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.id, parseInt(id)));
|
||||
|
||||
authLogger.success(
|
||||
`Snippet updated: ${updated[0].name} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_update_success",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
name: updated[0].name,
|
||||
},
|
||||
);
|
||||
databaseLogger.info("Command snippet updated", {
|
||||
operation: "snippet_update",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
});
|
||||
|
||||
res.json(updated[0]);
|
||||
} catch (err) {
|
||||
@@ -883,15 +1161,37 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a snippet
|
||||
// DELETE /snippets/:id
|
||||
/**
|
||||
* @openapi
|
||||
* /snippets/{id}:
|
||||
* delete:
|
||||
* summary: Delete a snippet
|
||||
* description: Deletes a specific snippet by its ID.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Snippet deleted successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Snippet not found.
|
||||
* 500:
|
||||
* description: Failed to delete snippet.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for snippet delete");
|
||||
@@ -911,16 +1211,11 @@ router.delete(
|
||||
await db
|
||||
.delete(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
authLogger.success(
|
||||
`Snippet deleted: ${existing[0].name} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_delete_success",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
name: existing[0].name,
|
||||
},
|
||||
);
|
||||
databaseLogger.info("Command snippet deleted", {
|
||||
operation: "snippet_delete",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "../db/index.js";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -17,8 +17,33 @@ const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Save command to history
|
||||
// POST /terminal/command_history
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history:
|
||||
* post:
|
||||
* summary: Save command to history
|
||||
* description: Saves a command to the command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Command saved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to save command.
|
||||
*/
|
||||
router.post(
|
||||
"/command_history",
|
||||
authenticateJWT,
|
||||
@@ -37,11 +62,37 @@ router.post(
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sensitivePatterns = [
|
||||
/passw(or)?d/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/PASS(WORD)?=/i,
|
||||
/AWS_SECRET/i,
|
||||
/mysql\b.*-p/i,
|
||||
/sudo\s+-S\b/,
|
||||
/htpasswd/i,
|
||||
/sshpass/i,
|
||||
/curl\b.*-u\s/i,
|
||||
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: command.trim(),
|
||||
command: trimmedCommand,
|
||||
};
|
||||
|
||||
const result = await db
|
||||
@@ -59,15 +110,37 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Get command history for a specific host
|
||||
// GET /terminal/command_history/:hostId
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: Retrieves the command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid request parameters.
|
||||
* 500:
|
||||
* description: Failed to fetch history.
|
||||
*/
|
||||
router.get(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.params;
|
||||
const hostId = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
@@ -107,8 +180,33 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a specific command from history
|
||||
// POST /terminal/command_history/delete
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/delete:
|
||||
* post:
|
||||
* summary: Delete a specific command from history
|
||||
* description: Deletes a specific command from the history of a host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.post(
|
||||
"/command_history/delete",
|
||||
authenticateJWT,
|
||||
@@ -150,15 +248,37 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Clear command history for a specific host (optional feature)
|
||||
// DELETE /terminal/command_history/:hostId
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/command_history/{hostId}:
|
||||
* delete:
|
||||
* summary: Clear command history
|
||||
* description: Clears the entire command history for a specific host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command history cleared successfully.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 500:
|
||||
* description: Failed to clear history.
|
||||
*/
|
||||
router.delete(
|
||||
"/command_history/:hostId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId } = req.params;
|
||||
const hostId = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostIdNum = parseInt(hostId, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(hostIdNum)) {
|
||||
@@ -175,9 +295,8 @@ router.delete(
|
||||
eq(commandHistory.hostId, hostIdNum),
|
||||
),
|
||||
);
|
||||
|
||||
authLogger.success(`Command history cleared for host ${hostId}`, {
|
||||
operation: "command_history_clear_success",
|
||||
databaseLogger.info("Terminal history cleared", {
|
||||
operation: "terminal_history_clear",
|
||||
userId,
|
||||
hostId: hostIdNum,
|
||||
});
|
||||
@@ -192,4 +311,118 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/session_settings:
|
||||
* get:
|
||||
* summary: Get session persistence settings
|
||||
* description: Returns the session timeout and persistence enabled flag.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session settings.
|
||||
* 500:
|
||||
* description: Failed to fetch settings.
|
||||
*/
|
||||
router.get(
|
||||
"/session_settings",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const timeoutRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const enabledRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
res.json({
|
||||
timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30,
|
||||
enabled: enabledRow ? enabledRow.value === "true" : true,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch session settings", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch settings",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /terminal/session_settings:
|
||||
* post:
|
||||
* summary: Update session persistence settings
|
||||
* description: Saves session timeout and persistence enabled flag.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* timeoutMinutes:
|
||||
* type: integer
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settings saved successfully.
|
||||
* 400:
|
||||
* description: Invalid parameters.
|
||||
* 500:
|
||||
* description: Failed to save settings.
|
||||
*/
|
||||
router.post(
|
||||
"/session_settings",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { timeoutMinutes, enabled } = req.body;
|
||||
|
||||
if (
|
||||
timeoutMinutes !== undefined &&
|
||||
(typeof timeoutMinutes !== "number" ||
|
||||
timeoutMinutes < 1 ||
|
||||
timeoutMinutes > 1440)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "timeoutMinutes must be between 1 and 1440" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (timeoutMinutes !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)",
|
||||
)
|
||||
.run(String(timeoutMinutes));
|
||||
}
|
||||
|
||||
if (enabled !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)",
|
||||
)
|
||||
.run(String(enabled));
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to save session settings", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to save settings",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import GuacamoleLite from "guacamole-lite";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||
const parts = url.split(":");
|
||||
return {
|
||||
host: parts[0] || "localhost",
|
||||
port: parseInt(parts[1] || "4822", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function readGuacdOptions(): { host: string; port: number } {
|
||||
let host = process.env.GUACD_HOST || "localhost";
|
||||
let port = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parsed = parseGuacUrl(urlRow.value);
|
||||
host = parsed.host;
|
||||
port = parsed.port;
|
||||
}
|
||||
} catch {
|
||||
// DB not available yet, use env var defaults
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: "ERRORS",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "));
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "));
|
||||
},
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
cursor: "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _origConsoleLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
const msg = args[0];
|
||||
if (typeof msg === "string" && msg.startsWith("New client connection"))
|
||||
return;
|
||||
_origConsoleLog(...args);
|
||||
};
|
||||
|
||||
function createGuacServer(): GuacamoleLite {
|
||||
const guacdOptions = readGuacdOptions();
|
||||
const server = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on(
|
||||
"open",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"close",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(
|
||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||
error: Error,
|
||||
) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
let guacServer = createGuacServer();
|
||||
|
||||
export async function restartGuacServer(): Promise<void> {
|
||||
try {
|
||||
guacServer.close();
|
||||
} catch (err) {
|
||||
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||
}
|
||||
guacServer = createGuacServer();
|
||||
}
|
||||
|
||||
export { guacServer, tokenService };
|
||||
@@ -0,0 +1,303 @@
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
/**
|
||||
* POST /guacamole/token
|
||||
* Generate an encrypted connection token for guacamole-lite
|
||||
*
|
||||
* Body: {
|
||||
* type: "rdp" | "vnc" | "telnet",
|
||||
* hostname: string,
|
||||
* port?: number,
|
||||
* username?: string,
|
||||
* password?: string,
|
||||
* domain?: string,
|
||||
* // Additional protocol-specific options
|
||||
* }
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { type, hostname, port, username, password, domain, ...options } =
|
||||
req.body;
|
||||
|
||||
if (!type || !hostname) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing required fields: type and hostname" });
|
||||
}
|
||||
|
||||
if (!["rdp", "vnc", "telnet"].includes(type)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid connection type. Must be rdp, vnc, or telnet",
|
||||
});
|
||||
}
|
||||
|
||||
let token: string;
|
||||
|
||||
switch (type) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username || "",
|
||||
password || "",
|
||||
{
|
||||
port: port || 3389,
|
||||
domain,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...options,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token", error, {
|
||||
operation: "guac_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /guacamole/connect-host/{hostId}:
|
||||
* post:
|
||||
* summary: Generate Guacamole connection token from host configuration
|
||||
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
|
||||
* tags:
|
||||
* - Guacamole
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Host ID to connect to
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection token generated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: Encrypted connection token
|
||||
* 400:
|
||||
* description: Invalid request or unsupported connection type
|
||||
* 403:
|
||||
* description: Access denied to host
|
||||
* 404:
|
||||
* description: Host not found
|
||||
* 500:
|
||||
* description: Server error
|
||||
*/
|
||||
router.post(
|
||||
"/connect-host/:hostId",
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = parseInt(req.params.hostId, 10);
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
|
||||
if (host.userId !== userId) {
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"read",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
guacLogger.warn("User attempted to access host without permission", {
|
||||
operation: "guac_access_denied",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
}
|
||||
|
||||
const connectionType = (host.connectionType as string) || "ssh";
|
||||
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
|
||||
return res.status(400).json({
|
||||
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
let guacConfig: Record<string, unknown> = {};
|
||||
if (host.guacamoleConfig) {
|
||||
try {
|
||||
guacConfig =
|
||||
typeof host.guacamoleConfig === "string"
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: (host.guacamoleConfig as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
guacLogger.warn("Failed to parse guacamole config", {
|
||||
operation: "guac_config_parse_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let token: string;
|
||||
const hostname = host.ip as string;
|
||||
const port = host.port as number;
|
||||
const username = (host.username as string) || "";
|
||||
const password = (host.password as string) || "";
|
||||
const domain = (host.domain as string) || "";
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
token = tokenService.createRdpToken(hostname, username, password, {
|
||||
port: port || 3389,
|
||||
domain,
|
||||
security: (host.security as string) || undefined,
|
||||
"ignore-cert": (host.ignoreCert as boolean) || false,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(hostname, password, {
|
||||
port: port || 5900,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port: port || 23,
|
||||
...guacConfig,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||
operation: "guac_host_token_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to generate connection token" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /guacamole/status
|
||||
* Check if guacd is reachable
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
let guacdHost = process.env.GUACD_HOST || "localhost";
|
||||
let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
try {
|
||||
const db = getDb();
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (urlRow?.value) {
|
||||
const parts = urlRow.value.split(":");
|
||||
guacdHost = parts[0] || guacdHost;
|
||||
guacdPort = parseInt(parts[1] || String(guacdPort), 10);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to env vars
|
||||
}
|
||||
|
||||
const net = await import("net");
|
||||
|
||||
const checkConnection = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(3000);
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.destroy();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
socket.connect(guacdPort, guacdHost);
|
||||
});
|
||||
};
|
||||
|
||||
const isConnected = await checkConnection();
|
||||
|
||||
res.json({
|
||||
guacd: {
|
||||
host: guacdHost,
|
||||
port: guacdPort,
|
||||
status: isConnected ? "connected" : "disconnected",
|
||||
},
|
||||
websocket: {
|
||||
port: 30008,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to check guacamole status", error, {
|
||||
operation: "guac_status_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,181 @@
|
||||
import crypto from "crypto";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
settings: {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
dpi?: number;
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32;
|
||||
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(jwtSecret + "_guacamole")
|
||||
.digest();
|
||||
}
|
||||
|
||||
guacLogger.warn(
|
||||
"No persistent encryption key found, generating random key",
|
||||
{
|
||||
operation: "guac_key_generation",
|
||||
},
|
||||
);
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
encryptToken(tokenObject: GuacamoleToken): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let encrypted = cipher.update(
|
||||
JSON.stringify(tokenObject),
|
||||
"utf8",
|
||||
"base64",
|
||||
);
|
||||
encrypted += cipher.final("base64");
|
||||
|
||||
const data = {
|
||||
iv: iv.toString("base64"),
|
||||
value: encrypted,
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
decryptToken(token: string): GuacamoleToken | null {
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||
const iv = Buffer.from(data.iv, "base64");
|
||||
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||
|
||||
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return JSON.parse(decrypted) as GuacamoleToken;
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||
operation: "guac_token_decrypt_error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createRdpToken(
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 3389,
|
||||
security: "nla",
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createVncToken(
|
||||
hostname: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
settings: {
|
||||
hostname,
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
createTelnetToken(
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
}
|
||||
@@ -99,5 +99,4 @@ main() {
|
||||
setup_ssl_certificates
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
main "$@"
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
interface ResolvedCredentials {
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: Buffer;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContext {
|
||||
userId: string;
|
||||
ws: WebSocket;
|
||||
hostId: number;
|
||||
isKeyboardInteractive: boolean;
|
||||
keyboardInteractiveResponded: boolean;
|
||||
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
|
||||
totpPromptSent: boolean;
|
||||
warpgateAuthPromptSent: boolean;
|
||||
totpTimeout: NodeJS.Timeout | null;
|
||||
warpgateAuthTimeout: NodeJS.Timeout | null;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export class SSHAuthManager {
|
||||
public context: AuthContext;
|
||||
|
||||
constructor(context: AuthContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async resolveCredentials(
|
||||
hostConfig: HostConfig,
|
||||
): Promise<ResolvedCredentials> {
|
||||
let resolvedCredentials: ResolvedCredentials = {
|
||||
username: hostConfig.username,
|
||||
authType: hostConfig.authType || "none",
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId),
|
||||
eq(sshCredentials.userId, this.context.userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
this.context.userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0];
|
||||
resolvedCredentials = {
|
||||
username: (cred.username as string) || hostConfig.username,
|
||||
password: (cred.password as string) || undefined,
|
||||
key: cred.privateKey
|
||||
? Buffer.from(cred.privateKey as string)
|
||||
: undefined,
|
||||
keyPassword: (cred.keyPassword as string) || undefined,
|
||||
authType: (cred.authType as string) || "none",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (hostConfig.password) {
|
||||
resolvedCredentials.password = hostConfig.password;
|
||||
resolvedCredentials.authType = "password";
|
||||
}
|
||||
|
||||
if (hostConfig.key) {
|
||||
resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8");
|
||||
resolvedCredentials.authType = "key";
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
resolvedCredentials.keyPassword = hostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedCredentials;
|
||||
}
|
||||
|
||||
handleKeyboardInteractive(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
|
||||
const warpgatePattern = /warpgate\s+authentication/i;
|
||||
const isWarpgate =
|
||||
warpgatePattern.test(name) ||
|
||||
warpgatePattern.test(instructions) ||
|
||||
promptTexts.some((p) => warpgatePattern.test(p));
|
||||
|
||||
if (isWarpgate) {
|
||||
this.handleWarpgateAuth(name, instructions, promptTexts, finish);
|
||||
return;
|
||||
}
|
||||
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
this.handleTotpAuth(
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
finish,
|
||||
resolvedCredentials,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials);
|
||||
}
|
||||
|
||||
private handleWarpgateAuth(
|
||||
name: string,
|
||||
instructions: string,
|
||||
promptTexts: string[],
|
||||
finish: (responses: string[]) => void,
|
||||
): void {
|
||||
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
|
||||
|
||||
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
|
||||
const keyMatch = fullText.match(
|
||||
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
|
||||
);
|
||||
|
||||
if (urlMatch) {
|
||||
this.context.keyboardInteractiveFinish = () => {
|
||||
finish([""]);
|
||||
};
|
||||
|
||||
this.context.warpgateAuthPromptSent = true;
|
||||
|
||||
this.sendLog("auth", "info", "Warpgate authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "warpgate_auth_required",
|
||||
url: urlMatch[0],
|
||||
securityKey: keyMatch ? keyMatch[1] : "N/A",
|
||||
instructions: instructions,
|
||||
}),
|
||||
);
|
||||
|
||||
this.context.warpgateAuthTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
sshLogger.warn("Warpgate authentication timeout", {
|
||||
operation: "warpgate_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Warpgate authentication timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 300000);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTotpAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
totpPromptIndex: number,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
): void {
|
||||
if (this.context.totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - invalid code", {
|
||||
operation: "ssh_keyboard_interactive_totp_retry",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
authLogger.warn("TOTP verification failed for SSH session", {
|
||||
operation: "terminal_totp_failed",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.sendLog("auth", "warning", "Invalid TOTP code");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_retry",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.totpPromptSent = true;
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||
const totpCode = (totpResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
sshLogger.warn("TOTP prompt timeout", {
|
||||
operation: "totp_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
this.sendLog("auth", "info", "TOTP verification required");
|
||||
authLogger.info("TOTP verification prompt sent to client", {
|
||||
operation: "terminal_totp_prompt",
|
||||
userId: this.context.userId,
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private handlePasswordAuth(
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
): void {
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
if (this.context.keyboardInteractiveResponded) {
|
||||
return;
|
||||
}
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === passwordPromptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
}
|
||||
|
||||
this.context.totpTimeout = setTimeout(() => {
|
||||
if (this.context.keyboardInteractiveFinish) {
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: this.context.hostId,
|
||||
});
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
this.sendLog("auth", "info", "Password authentication required");
|
||||
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
}
|
||||
|
||||
sendLog(
|
||||
stage: string,
|
||||
level: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): void {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "connection_log",
|
||||
data: {
|
||||
stage,
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.context.totpTimeout) {
|
||||
clearTimeout(this.context.totpTimeout);
|
||||
this.context.totpTimeout = null;
|
||||
}
|
||||
|
||||
if (this.context.warpgateAuthTimeout) {
|
||||
clearTimeout(this.context.warpgateAuthTimeout);
|
||||
this.context.warpgateAuthTimeout = null;
|
||||
}
|
||||
|
||||
this.context.keyboardInteractiveFinish = null;
|
||||
this.context.totpPromptSent = false;
|
||||
this.context.warpgateAuthPromptSent = false;
|
||||
this.context.keyboardInteractiveResponded = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: import("ssh2").ClientChannel | null;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
hostId?: number;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "0.0.0.0",
|
||||
port: 30009,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!decoded || !decoded.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
containerId: string,
|
||||
): Promise<string> {
|
||||
const shells = ["bash", "sh", "ash"];
|
||||
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.client.exec(
|
||||
`docker exec ${containerId} which ${shell}`,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
const jumpHostData = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (jumpHostData.length === 0) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = jumpHostData[0] as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
} catch (e) {
|
||||
sshLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: jumpHost.id,
|
||||
});
|
||||
jumpHost.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: jumpHost.password,
|
||||
sshKey: jumpHost.key,
|
||||
keyPassword: jumpHost.keyPassword,
|
||||
authType: jumpHost.authType,
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, jumpHost.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentClient) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentClient!.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHost.ip,
|
||||
jumpHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
config.sock = stream;
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
currentClient = client;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const url = parseUrl(req.url || "", true);
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
const payload = await authManagerInstance.verifyJWTToken(token);
|
||||
if (!payload || !payload.userId) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const { hostConfig, containerId, shell, cols, rows } =
|
||||
message.data as {
|
||||
hostConfig: SSHHost;
|
||||
containerId: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
const hostId = hostConfig?.id;
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
|
||||
port: resolvedHost.port || 22,
|
||||
username: resolvedHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (resolvedHost.authType === "password" && resolvedHost.password) {
|
||||
config.password = resolvedHost.password;
|
||||
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
|
||||
const cleanKey = resolvedHost.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedHost.keyPassword) {
|
||||
config.passphrase = resolvedHost.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
resolvedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
const stream = await new Promise<import("ssh2").ClientChannel>(
|
||||
(resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
resolvedHost.ip,
|
||||
resolvedHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
config.sock = stream;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: resolvedHost.id,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
let shellToUse = shell || "bash";
|
||||
|
||||
if (shell) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.exec(
|
||||
`docker exec ${containerId} which ${shell}`,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not available`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
sshLogger.warn(
|
||||
`Requested shell ${shell} not found, detecting available shell`,
|
||||
{
|
||||
operation: "shell_validation",
|
||||
sessionId,
|
||||
containerId,
|
||||
requestedShell: shell,
|
||||
},
|
||||
);
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
} else {
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
|
||||
sshSession.shell = shellToUse;
|
||||
|
||||
const execCommand = `docker exec -it ${containerId} /bin/${shellToUse}`;
|
||||
sshLogger.info("Attaching to Docker container", {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Failed to create docker exec", err, {
|
||||
operation: "docker_exec",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshSession!.stream = stream;
|
||||
sshLogger.success("Docker container attached", {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: data.toString("utf8"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Console session ended",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (sshSession) {
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: shell && shell !== shellToUse,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to connect to container", error, {
|
||||
operation: "console_connect",
|
||||
sessionId,
|
||||
containerId: message.data.containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to connect to container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
sshSession.stream.write(message.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const { cols, rows } = message.data;
|
||||
sshSession.stream.setWindow(rows, cols, rows, cols);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ping": {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket message error", error, {
|
||||
operation: "ws_message",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "An error occurred",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
clearInterval(wsPingInterval);
|
||||
sshLogger.info("Docker console disconnected", {
|
||||
operation: "docker_console_disconnect",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: sshSession?.hostId,
|
||||
containerId: sshSession?.containerId,
|
||||
});
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
activeSessions.forEach((session) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
wss.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
interface HostKeyVerificationData {
|
||||
scenario: "new" | "changed";
|
||||
ip: string;
|
||||
port: number;
|
||||
hostname?: string;
|
||||
fingerprint: string;
|
||||
oldFingerprint?: string;
|
||||
keyType: string;
|
||||
oldKeyType?: string;
|
||||
algorithm: string;
|
||||
}
|
||||
|
||||
interface VerificationResponse {
|
||||
action: "accept" | "reject";
|
||||
}
|
||||
|
||||
export class SSHHostKeyVerifier {
|
||||
static async createHostVerifier(
|
||||
hostId: number | null,
|
||||
ip: string,
|
||||
port: number,
|
||||
ws: WebSocket | null,
|
||||
userId: string,
|
||||
isJumpHost: boolean = false,
|
||||
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
|
||||
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
|
||||
(async () => {
|
||||
try {
|
||||
const fingerprint = hostkey.toString("hex");
|
||||
const keyType = this.getKeyType(hostkey);
|
||||
const algorithm = "sha256";
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.info(
|
||||
"Host key verification skipped (no hostId - quick connect)",
|
||||
{
|
||||
operation: "host_key_skip",
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await db.query.hosts.findFirst({
|
||||
where: eq(hosts.id, hostId),
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
sshLogger.warn(
|
||||
"Host not found in database during key verification",
|
||||
{
|
||||
operation: "host_key_no_host",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!host.hostKeyFingerprint) {
|
||||
if (isJumpHost) {
|
||||
await this.storeHostKey(hostId, fingerprint, keyType, algorithm);
|
||||
sshLogger.info("Jump host key auto-accepted and stored", {
|
||||
operation: "host_key_stored",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
isJumpHost: true,
|
||||
});
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws) {
|
||||
sshLogger.warn(
|
||||
"No WebSocket available for host key verification prompt",
|
||||
{
|
||||
operation: "host_key_no_ws",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = await this.promptUserForNewKey(
|
||||
ws,
|
||||
ip,
|
||||
port,
|
||||
host.name || undefined,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
);
|
||||
|
||||
if (accepted) {
|
||||
await this.storeHostKey(hostId, fingerprint, keyType, algorithm);
|
||||
sshLogger.info("New host key accepted by user and stored", {
|
||||
operation: "host_key_stored",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
sshLogger.warn("User rejected new host key", {
|
||||
operation: "host_key_rejected",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
verify(accepted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (host.hostKeyFingerprint === fingerprint) {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
|
||||
sshLogger.info("Host key verified successfully", {
|
||||
operation: "host_key_verified",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
});
|
||||
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
sshLogger.error("Host key mismatch detected - SECURITY WARNING", {
|
||||
operation: "host_key_mismatch",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
oldFingerprint: host.hostKeyFingerprint,
|
||||
newFingerprint: fingerprint,
|
||||
oldKeyType: host.hostKeyType,
|
||||
newKeyType: keyType,
|
||||
userId,
|
||||
changeCount: host.hostKeyChangedCount || 0,
|
||||
});
|
||||
|
||||
if (isJumpHost) {
|
||||
await this.updateHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
host.hostKeyChangedCount || 0,
|
||||
);
|
||||
sshLogger.warn("Jump host key changed - auto-accepted", {
|
||||
operation: "host_key_updated",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
isJumpHost: true,
|
||||
});
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws) {
|
||||
sshLogger.error(
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
{
|
||||
operation: "host_key_no_ws_reject",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
message:
|
||||
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||
},
|
||||
);
|
||||
verify(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = await this.promptUserForChangedKey(
|
||||
ws,
|
||||
ip,
|
||||
port,
|
||||
host.name || undefined,
|
||||
fingerprint,
|
||||
host.hostKeyFingerprint,
|
||||
keyType,
|
||||
host.hostKeyType || "unknown",
|
||||
algorithm,
|
||||
);
|
||||
|
||||
if (accepted) {
|
||||
await this.updateHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
host.hostKeyChangedCount || 0,
|
||||
);
|
||||
sshLogger.warn("Changed host key accepted by user", {
|
||||
operation: "host_key_updated",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
oldFingerprint: host.hostKeyFingerprint,
|
||||
newFingerprint: fingerprint,
|
||||
userId,
|
||||
changeCount: (host.hostKeyChangedCount || 0) + 1,
|
||||
});
|
||||
} else {
|
||||
sshLogger.error("User rejected changed host key", {
|
||||
operation: "host_key_change_rejected",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
verify(accepted);
|
||||
} catch (error) {
|
||||
sshLogger.error("Error in host key verification", error, {
|
||||
operation: "host_key_error",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
});
|
||||
verify(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
private static async storeHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyFirstSeen: new Date().toISOString(),
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
}
|
||||
|
||||
private static async updateHostKey(
|
||||
hostId: number,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
currentChangeCount: number,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyFingerprint: fingerprint,
|
||||
hostKeyType: keyType,
|
||||
hostKeyAlgorithm: algorithm,
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
hostKeyChangedCount: currentChangeCount + 1,
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
}
|
||||
|
||||
private static async promptUserForNewKey(
|
||||
ws: WebSocket,
|
||||
ip: string,
|
||||
port: number,
|
||||
hostname: string | undefined,
|
||||
fingerprint: string,
|
||||
keyType: string,
|
||||
algorithm: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.removeListener("message", messageHandler);
|
||||
sshLogger.warn("Host key verification timeout (new key)", {
|
||||
operation: "host_key_timeout",
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
resolve(false);
|
||||
}, 60000);
|
||||
|
||||
const messageHandler = (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === "host_key_verification_response") {
|
||||
clearTimeout(timeout);
|
||||
ws.removeListener("message", messageHandler);
|
||||
|
||||
const response = message.data as VerificationResponse;
|
||||
resolve(response.action === "accept");
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
"Error parsing host key verification response",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("message", messageHandler);
|
||||
|
||||
const verificationData: HostKeyVerificationData = {
|
||||
scenario: "new",
|
||||
ip,
|
||||
port,
|
||||
hostname,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "host_key_verification_required",
|
||||
data: verificationData,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static async promptUserForChangedKey(
|
||||
ws: WebSocket,
|
||||
ip: string,
|
||||
port: number,
|
||||
hostname: string | undefined,
|
||||
fingerprint: string,
|
||||
oldFingerprint: string,
|
||||
keyType: string,
|
||||
oldKeyType: string,
|
||||
algorithm: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.removeListener("message", messageHandler);
|
||||
sshLogger.error("Host key verification timeout (changed key)", {
|
||||
operation: "host_key_timeout",
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
resolve(false);
|
||||
}, 120000);
|
||||
|
||||
const messageHandler = (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
if (message.type === "host_key_verification_response") {
|
||||
clearTimeout(timeout);
|
||||
ws.removeListener("message", messageHandler);
|
||||
|
||||
const response = message.data as VerificationResponse;
|
||||
resolve(response.action === "accept");
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
"Error parsing host key verification response",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.on("message", messageHandler);
|
||||
|
||||
const verificationData: HostKeyVerificationData = {
|
||||
scenario: "changed",
|
||||
ip,
|
||||
port,
|
||||
hostname,
|
||||
fingerprint,
|
||||
oldFingerprint,
|
||||
keyType,
|
||||
oldKeyType,
|
||||
algorithm,
|
||||
};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "host_key_changed",
|
||||
data: verificationData,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private static getKeyType(key: Buffer): string {
|
||||
try {
|
||||
if (key.length < 4) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const typeLength = key.readUInt32BE(0);
|
||||
if (typeLength > key.length - 4 || typeLength > 256) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const keyType = key.toString("utf8", 4, 4 + typeLength);
|
||||
|
||||
if (
|
||||
(keyType && keyType.startsWith("ssh-")) ||
|
||||
keyType.startsWith("ecdsa-")
|
||||
) {
|
||||
return keyType;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
} catch (error) {
|
||||
sshLogger.error("Error parsing SSH key type", error);
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
/**
|
||||
* Resolve a host with its credentials server-side by hostId.
|
||||
* This avoids passing credentials through the frontend.
|
||||
*/
|
||||
export async function resolveHostById(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<SSHHost | null> {
|
||||
const db = getDb();
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
db.select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) return null;
|
||||
|
||||
const host = hostResults[0] as Record<string, unknown>;
|
||||
|
||||
// Parse JSON fields
|
||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||
try {
|
||||
host.jumpHosts = JSON.parse(host.jumpHosts as string);
|
||||
} catch {
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.tunnelConnections === "string") {
|
||||
try {
|
||||
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
|
||||
} catch {
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.statsConfig === "string" && host.statsConfig) {
|
||||
try {
|
||||
host.statsConfig = JSON.parse(host.statsConfig as string);
|
||||
} catch {
|
||||
host.statsConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
} catch {
|
||||
host.terminalConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
} catch {
|
||||
host.socks5ProxyChain = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.quickActions === "string" && host.quickActions) {
|
||||
try {
|
||||
host.quickActions = JSON.parse(host.quickActions as string);
|
||||
} catch {
|
||||
host.quickActions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credential if using credential-based auth
|
||||
if (host.credentialId) {
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
try {
|
||||
// Try shared credential first for non-owner users
|
||||
if (userId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
hostId,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
host.password = sharedCred.password;
|
||||
host.key = sharedCred.key;
|
||||
host.keyPassword = sharedCred.keyPassword;
|
||||
host.keyType = sharedCred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = sharedCred.username;
|
||||
}
|
||||
host.authType = sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none";
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared credential, falling back", {
|
||||
operation: "host_resolver_shared_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = cred.username;
|
||||
}
|
||||
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
operation: "host_resolver_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: "read" | "execute" = "execute",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
requiredPermission,
|
||||
);
|
||||
return accessInfo.hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { randomUUID } from "crypto";
|
||||
import { WebSocket } from "ws";
|
||||
import { IncomingMessage } from "http";
|
||||
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { opksshTokens } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import yaml from "js-yaml";
|
||||
import { getRequestOrigin } from "../utils/request-origin.js";
|
||||
|
||||
const AUTH_TIMEOUT = 60 * 1000;
|
||||
|
||||
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
|
||||
|
||||
interface OPKSSHAuthSession {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostname: string;
|
||||
process: ChildProcess;
|
||||
localPort: number;
|
||||
callbackPort: number;
|
||||
remoteRedirectUri: string;
|
||||
providers: Array<{ alias: string; issuer: string }>;
|
||||
status:
|
||||
| "starting"
|
||||
| "waiting_for_auth"
|
||||
| "authenticating"
|
||||
| "completed"
|
||||
| "error";
|
||||
ws: WebSocket;
|
||||
stdoutBuffer: string;
|
||||
privateKeyBuffer: string;
|
||||
sshCertBuffer: string;
|
||||
identity: {
|
||||
email?: string;
|
||||
sub?: string;
|
||||
issuer?: string;
|
||||
audience?: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
approvalTimeout: NodeJS.Timeout;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
|
||||
const oauthStateToRequestId = new Map<string, string>();
|
||||
const cleanupInProgress = new Set<string>();
|
||||
|
||||
function getOPKConfigPath(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, ".opk", "config.yml");
|
||||
}
|
||||
|
||||
async function ensureOPKConfigDir(): Promise<void> {
|
||||
const configPath = getOPKConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function createTemplateConfig(): Promise<void> {
|
||||
const configPath = getOPKConfigPath();
|
||||
const template = `
|
||||
# OPKSSH Configuration
|
||||
# OPKSSH Documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md
|
||||
# Termix Documentation: https://docs.termix.site/opkssh
|
||||
`;
|
||||
|
||||
try {
|
||||
await ensureOPKConfigDir();
|
||||
await fs.writeFile(configPath, template, "utf8");
|
||||
sshLogger.info(`Created template OPKSSH config at ${configPath}`);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to create template OPKSSH config", error);
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderRedirectInfo {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirectUris: string[];
|
||||
}
|
||||
|
||||
async function checkOPKConfigExists(): Promise<{
|
||||
exists: boolean;
|
||||
error?: string;
|
||||
configPath?: string;
|
||||
providers?: ProviderRedirectInfo[];
|
||||
}> {
|
||||
const configPath = getOPKConfigPath();
|
||||
const isDocker =
|
||||
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
|
||||
const dockerHint = isDocker
|
||||
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
|
||||
: "";
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(configPath, "utf8");
|
||||
|
||||
if (!content.includes("providers:")) {
|
||||
return {
|
||||
exists: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration is missing 'providers' section. Please edit the config file at:\n${configPath}\n\n.`,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
|
||||
const hasUncommentedProvider = lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return (
|
||||
trimmed.startsWith("- alias:") ||
|
||||
(trimmed.startsWith("issuer:") && !line.trimStart().startsWith("#"))
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasUncommentedProvider) {
|
||||
return {
|
||||
exists: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration has no active providers. Please edit the config file at:\n${configPath}\n\nUncomment and configure at least one OIDC provider.\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
|
||||
};
|
||||
}
|
||||
|
||||
let providers: ProviderRedirectInfo[] = [];
|
||||
try {
|
||||
const parsed = yaml.load(content) as {
|
||||
providers?: Array<{
|
||||
alias?: string;
|
||||
issuer?: string;
|
||||
redirect_uris?: string[];
|
||||
}>;
|
||||
};
|
||||
if (parsed?.providers && Array.isArray(parsed.providers)) {
|
||||
providers = parsed.providers
|
||||
.filter(
|
||||
(
|
||||
p,
|
||||
): p is {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirect_uris?: string[];
|
||||
} => typeof p.alias === "string" && typeof p.issuer === "string",
|
||||
)
|
||||
.map((p) => ({
|
||||
alias: p.alias,
|
||||
issuer: p.issuer.replace(/^https?:\/\//, ""),
|
||||
redirectUris: Array.isArray(p.redirect_uris)
|
||||
? p.redirect_uris.filter(
|
||||
(u): u is string => typeof u === "string",
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to parse OPKSSH config for providers", {
|
||||
operation: "opkssh_config_parse_providers_error",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
return { exists: true, configPath, providers };
|
||||
} catch {
|
||||
await createTemplateConfig();
|
||||
return {
|
||||
exists: false,
|
||||
configPath,
|
||||
error: `OPKSSH configuration not found. A template config file has been created at:\n${configPath}\n\nPlease edit this file and configure your OIDC provider (Google, GitHub, Microsoft, etc.).\nSee documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md${dockerHint}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
|
||||
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
|
||||
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
|
||||
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
|
||||
// (derived from request origin); users do NOT put that URL in this config field.
|
||||
function validateRedirectUrisAreLocalhost(
|
||||
providers: ProviderRedirectInfo[],
|
||||
): { ok: true } | { ok: false; message: string } {
|
||||
const isLocalHost = (host: string): boolean => {
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
bare === "localhost" ||
|
||||
bare === "127.0.0.1" ||
|
||||
bare === "::1" ||
|
||||
bare === "0:0:0:0:0:0:0:1" ||
|
||||
bare.startsWith("localhost:") ||
|
||||
bare.startsWith("127.0.0.1:")
|
||||
);
|
||||
};
|
||||
|
||||
const issues: string[] = [];
|
||||
for (const p of providers) {
|
||||
const uris = p.redirectUris || [];
|
||||
if (uris.length === 0) continue;
|
||||
const nonLocal = uris.filter((u) => {
|
||||
try {
|
||||
return !isLocalHost(new URL(u).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (nonLocal.length > 0) {
|
||||
issues.push(
|
||||
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
|
||||
`${issues.join("\n")}\n\n` +
|
||||
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
|
||||
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
|
||||
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
|
||||
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
|
||||
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
|
||||
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
|
||||
`Docs: https://docs.termix.site/opkssh`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function startOPKSSHAuth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
hostname: string,
|
||||
ws: WebSocket,
|
||||
requestOrigin: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await ensureOPKConfigDir();
|
||||
const configDir = path.dirname(getOPKConfigPath());
|
||||
await fs.access(configDir, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch (error) {
|
||||
sshLogger.error("OPKSSH directory not accessible", error);
|
||||
const isDocker =
|
||||
!!process.env.DATA_DIR && process.env.DATA_DIR.startsWith("/app");
|
||||
const dockerHint = isDocker
|
||||
? "\n\nDocker: Ensure /app/data is mounted as a volume with write permissions for node:node user."
|
||||
: "";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
error: `OPKSSH directory initialization failed: ${error.message}${dockerHint}`,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const configCheck = await checkOPKConfigExists();
|
||||
if (!configCheck.exists) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: configCheck.error,
|
||||
instructions: configCheck.error,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const redirectValidation = validateRedirectUrisAreLocalhost(
|
||||
configCheck.providers || [],
|
||||
);
|
||||
if (redirectValidation.ok === false) {
|
||||
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
|
||||
operation: "opkssh_config_redirect_uris_not_localhost",
|
||||
configPath: configCheck.configPath,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: redirectValidation.message,
|
||||
instructions: redirectValidation.message,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
|
||||
|
||||
sshLogger.info("Starting OPKSSH auth session", {
|
||||
operation: "opkssh_start_auth_remote_redirect_uri",
|
||||
requestId,
|
||||
userId,
|
||||
hostId,
|
||||
requestOrigin,
|
||||
remoteRedirectUri,
|
||||
providerAliases: (configCheck.providers || []).map((p) => p.alias),
|
||||
});
|
||||
|
||||
const session: Partial<OPKSSHAuthSession> = {
|
||||
requestId,
|
||||
userId,
|
||||
hostId,
|
||||
hostname,
|
||||
localPort: 0,
|
||||
callbackPort: 0,
|
||||
remoteRedirectUri,
|
||||
providers: configCheck.providers || [],
|
||||
status: "starting",
|
||||
ws,
|
||||
stdoutBuffer: "",
|
||||
privateKeyBuffer: "",
|
||||
sshCertBuffer: "",
|
||||
identity: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
|
||||
const configPath = getOPKConfigPath();
|
||||
|
||||
const args = [
|
||||
"login",
|
||||
"--print-key",
|
||||
"--disable-browser-open",
|
||||
`--config-path=${configPath}`,
|
||||
`--remote-redirect-uri=${remoteRedirectUri}`,
|
||||
];
|
||||
|
||||
const opksshProcess = spawn(binaryPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
session.process = opksshProcess;
|
||||
|
||||
const cleanup = async () => {
|
||||
await cleanupAuthSession(requestId);
|
||||
};
|
||||
session.cleanup = cleanup;
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
sshLogger.warn(`OPKSSH auth timeout for session ${requestId}`);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_timeout",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
}, AUTH_TIMEOUT);
|
||||
|
||||
session.approvalTimeout = timeout;
|
||||
|
||||
ws.on("close", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
activeAuthSessions.set(requestId, session as OPKSSHAuthSession);
|
||||
|
||||
opksshProcess.stdout?.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
handleOPKSSHOutput(requestId, output);
|
||||
});
|
||||
|
||||
opksshProcess.stderr?.on("data", async (data) => {
|
||||
const stderr = data.toString();
|
||||
|
||||
if (
|
||||
stderr.includes("Opening browser to") ||
|
||||
stderr.includes("Open your browser to:")
|
||||
) {
|
||||
handleOPKSSHOutput(requestId, stderr);
|
||||
}
|
||||
|
||||
if (stderr.includes("listening on")) {
|
||||
handleOPKSSHOutput(requestId, stderr);
|
||||
}
|
||||
|
||||
const lowerStderr = stderr.toLowerCase();
|
||||
|
||||
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
|
||||
// with the distinctive message "redirectURI must be localhost". Surface that
|
||||
// directly with actionable guidance.
|
||||
if (lowerStderr.includes("redirecturi must be localhost")) {
|
||||
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
|
||||
operation: "opkssh_stderr_redirect_uris_not_localhost",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
|
||||
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
|
||||
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
|
||||
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
|
||||
const genericRedirectIndicators = [
|
||||
"redirect_uri",
|
||||
"redirect uri",
|
||||
"invalid redirect",
|
||||
"no matching redirect",
|
||||
"allowed redirect",
|
||||
"mismatching redirection",
|
||||
];
|
||||
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
|
||||
lowerStderr.includes(s),
|
||||
);
|
||||
|
||||
if (hasGenericRedirectError) {
|
||||
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
|
||||
operation: "opkssh_stderr_redirect_uri_error",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
|
||||
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
|
||||
`(e.g. in Google Cloud Console → OAuth client). ` +
|
||||
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stderr.includes("provider not found") ||
|
||||
stderr.includes("config error") ||
|
||||
stderr.includes("invalid config") ||
|
||||
stderr.includes("config not found")
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
"OPKSSH configuration error. Please verify your config file contains valid OIDC provider settings.",
|
||||
instructions:
|
||||
"See documentation: https://github.com/openpubkey/opkssh/blob/main/docs/config.md",
|
||||
}),
|
||||
);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
if (
|
||||
stderr.includes("level=error") ||
|
||||
stderr.includes("Error:") ||
|
||||
stderr.includes("failed")
|
||||
) {
|
||||
const isXdgOpenError = stderr.includes('exec: "xdg-open"');
|
||||
if (!isXdgOpenError) {
|
||||
if (
|
||||
stderr.includes("bind: address already in use") ||
|
||||
stderr.includes("error logging in") ||
|
||||
stderr.includes("failed to start")
|
||||
) {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
opksshProcess.on("error", (error) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `OPKSSH process error: ${error.message}`,
|
||||
}),
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
opksshProcess.on("exit", (code) => {
|
||||
if (code !== 0 && session.status !== "completed") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `OPKSSH process exited with code ${code}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return requestId;
|
||||
} catch (error) {
|
||||
sshLogger.error(`Failed to start OPKSSH auth session`, error);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: `Failed to start OPKSSH authentication: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
const session = activeAuthSessions.get(requestId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.stdoutBuffer += output;
|
||||
|
||||
const chooserUrlMatch = session.stdoutBuffer.match(
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
|
||||
);
|
||||
if (chooserUrlMatch && session.status === "starting") {
|
||||
const actualPort = parseInt(chooserUrlMatch[1], 10);
|
||||
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
|
||||
|
||||
session.localPort = actualPort;
|
||||
|
||||
const baseUrl = session.remoteRedirectUri
|
||||
.replace(/\/host\/opkssh-callback$/, "")
|
||||
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
|
||||
.replace(/:30002\b/, ":30001");
|
||||
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
||||
|
||||
session.status = "waiting_for_auth";
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_status",
|
||||
requestId,
|
||||
stage: "chooser",
|
||||
url: proxiedChooserUrl,
|
||||
providers: session.providers,
|
||||
localUrl: localChooserUrl,
|
||||
message: "Please authenticate in your browser",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const callbackPortMatch = session.stdoutBuffer.match(
|
||||
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
|
||||
);
|
||||
if (callbackPortMatch && !session.callbackPort) {
|
||||
session.callbackPort = parseInt(callbackPortMatch[1], 10);
|
||||
}
|
||||
|
||||
if (output.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
||||
session.status = "authenticating";
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_status",
|
||||
requestId,
|
||||
stage: "authenticating",
|
||||
message: "Processing authentication...",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const privateKeyMatch = session.stdoutBuffer.match(
|
||||
/(-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----)/,
|
||||
);
|
||||
if (privateKeyMatch) {
|
||||
session.privateKeyBuffer = privateKeyMatch[1].trim();
|
||||
}
|
||||
|
||||
const certMatch = session.stdoutBuffer.match(
|
||||
/(ecdsa-sha2-nistp256-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-rsa-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+|ssh-ed25519-cert-v01@openssh\.com\s+[A-Za-z0-9+/=]+)/,
|
||||
);
|
||||
if (certMatch) {
|
||||
session.sshCertBuffer = certMatch[1].trim();
|
||||
}
|
||||
|
||||
const identityMatch = session.stdoutBuffer.match(
|
||||
/Email, sub, issuer, audience:\s*\n?\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/,
|
||||
);
|
||||
if (identityMatch) {
|
||||
session.identity = {
|
||||
email: identityMatch[1],
|
||||
sub: identityMatch[2],
|
||||
issuer: identityMatch[3],
|
||||
audience: identityMatch[4],
|
||||
};
|
||||
}
|
||||
|
||||
if (session.privateKeyBuffer && session.sshCertBuffer) {
|
||||
if (!session.privateKeyBuffer.includes("BEGIN OPENSSH PRIVATE KEY")) {
|
||||
sshLogger.error(`Invalid private key extracted [${requestId}]`, {
|
||||
bufferPrefix: session.privateKeyBuffer.substring(0, 50),
|
||||
});
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: "Failed to extract valid private key from OPKSSH output",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.sshCertBuffer.match(/-cert-v01@openssh\.com/)) {
|
||||
sshLogger.error(`Invalid SSH certificate extracted [${requestId}]`, {
|
||||
bufferPrefix: session.sshCertBuffer.substring(0, 50),
|
||||
});
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: "Failed to extract valid SSH certificate from OPKSSH output",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
storeOPKSSHToken(session);
|
||||
}
|
||||
}
|
||||
|
||||
async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
const userDataKey = userCrypto.getUserDataKey(session.userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data key not found");
|
||||
}
|
||||
|
||||
const tokenId = `opkssh-${session.userId}-${session.hostId}`;
|
||||
|
||||
const encryptedCert = FieldCrypto.encryptField(
|
||||
session.sshCertBuffer,
|
||||
userDataKey,
|
||||
tokenId,
|
||||
"ssh_cert",
|
||||
);
|
||||
const encryptedKey = FieldCrypto.encryptField(
|
||||
session.privateKeyBuffer,
|
||||
userDataKey,
|
||||
tokenId,
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(opksshTokens)
|
||||
.values({
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
email: session.identity.email,
|
||||
sub: session.identity.sub,
|
||||
issuer: session.identity.issuer,
|
||||
audience: session.identity.audience,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [opksshTokens.userId, opksshTokens.hostId],
|
||||
set: {
|
||||
sshCert: encryptedCert,
|
||||
privateKey: encryptedKey,
|
||||
email: session.identity.email,
|
||||
sub: session.identity.sub,
|
||||
issuer: session.identity.issuer,
|
||||
audience: session.identity.audience,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
session.status = "completed";
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_completed",
|
||||
requestId: session.requestId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await session.cleanup();
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
`Failed to store OPKSSH token for session ${session.requestId}`,
|
||||
error,
|
||||
);
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId: session.requestId,
|
||||
error: "Failed to store authentication token",
|
||||
}),
|
||||
);
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOPKSSHToken(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<{ sshCert: string; privateKey: string } | null> {
|
||||
try {
|
||||
const db = getDb();
|
||||
const token = await db
|
||||
.select()
|
||||
.from(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!token || token.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenData = token[0];
|
||||
const expiresAt = new Date(tokenData.expiresAt);
|
||||
|
||||
if (expiresAt < new Date()) {
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
const userDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data key not found");
|
||||
}
|
||||
|
||||
const tokenId = `opkssh-${userId}-${hostId}`;
|
||||
const decryptedCert = FieldCrypto.decryptField(
|
||||
tokenData.sshCert,
|
||||
userDataKey,
|
||||
tokenId,
|
||||
"ssh_cert",
|
||||
);
|
||||
const decryptedKey = FieldCrypto.decryptField(
|
||||
tokenData.privateKey,
|
||||
userDataKey,
|
||||
tokenId,
|
||||
"private_key",
|
||||
);
|
||||
|
||||
await db
|
||||
.update(opksshTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
|
||||
return {
|
||||
sshCert: decryptedCert,
|
||||
privateKey: decryptedKey,
|
||||
};
|
||||
} catch (error) {
|
||||
sshLogger.error(`Failed to retrieve OPKSSH token`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteOPKSSHToken(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function invalidateOPKSSHToken(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = getDb();
|
||||
await db
|
||||
.delete(opksshTokens)
|
||||
.where(
|
||||
and(eq(opksshTokens.userId, userId), eq(opksshTokens.hostId, hostId)),
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error(`Failed to invalidate OPKSSH token`, {
|
||||
userId,
|
||||
hostId,
|
||||
reason,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(
|
||||
requestId: string,
|
||||
queryString: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const session = activeAuthSessions.get(requestId);
|
||||
|
||||
if (!session) {
|
||||
return { success: false, message: "Invalid authentication session" };
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
|
||||
await axios.get(callbackUrl, {
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
return { success: true };
|
||||
} catch {
|
||||
session.ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_error",
|
||||
requestId,
|
||||
error: "Failed to complete authentication",
|
||||
}),
|
||||
);
|
||||
await session.cleanup();
|
||||
return { success: false, message: "Authentication failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAuthSession(requestId: string): Promise<void> {
|
||||
if (cleanupInProgress.has(requestId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupInProgress.add(requestId);
|
||||
|
||||
try {
|
||||
const session = activeAuthSessions.get(requestId);
|
||||
if (!session) {
|
||||
cleanupInProgress.delete(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.approvalTimeout) {
|
||||
clearTimeout(session.approvalTimeout);
|
||||
}
|
||||
|
||||
if (session.process) {
|
||||
try {
|
||||
session.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
if (session.process && !session.process.killed) {
|
||||
session.process.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
session.process.once("exit", () => {
|
||||
clearTimeout(killTimeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
} catch (killError) {
|
||||
sshLogger.warn(
|
||||
`Failed to kill OPKSSH process for session ${requestId}`,
|
||||
killError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any OAuth state mappings for this session
|
||||
for (const [state, reqId] of oauthStateToRequestId.entries()) {
|
||||
if (reqId === requestId) {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
activeAuthSessions.delete(requestId);
|
||||
} finally {
|
||||
cleanupInProgress.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelAuthSession(requestId: string): void {
|
||||
const session = activeAuthSessions.get(requestId);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveAuthSession(
|
||||
requestId: string,
|
||||
): OPKSSHAuthSession | undefined {
|
||||
return activeAuthSessions.get(requestId);
|
||||
}
|
||||
|
||||
export function getActiveSessionsForUser(userId: string): OPKSSHAuthSession[] {
|
||||
const sessions: OPKSSHAuthSession[] = [];
|
||||
for (const session of activeAuthSessions.values()) {
|
||||
if (session.userId === userId) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export function getActiveSessionsAll(): OPKSSHAuthSession[] {
|
||||
return Array.from(activeAuthSessions.values());
|
||||
}
|
||||
|
||||
export function registerOAuthState(state: string, requestId: string): void {
|
||||
oauthStateToRequestId.set(state, requestId);
|
||||
}
|
||||
|
||||
export function getRequestIdByOAuthState(state: string): string | undefined {
|
||||
return oauthStateToRequestId.get(state);
|
||||
}
|
||||
|
||||
export function clearOAuthState(state: string): void {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
|
||||
export async function getUserIdFromRequest(req: {
|
||||
cookies?: Record<string, string>;
|
||||
headers: Record<string, string | undefined>;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
return decoded?.userId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// OPKSSH certificate authentication workarounds for ssh2.
|
||||
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
|
||||
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
||||
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
|
||||
interface OPKSSHToken {
|
||||
privateKey: string;
|
||||
sshCert: string;
|
||||
}
|
||||
export async function setupOPKSSHCertAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
token: OPKSSHToken,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
const parsed = parseKey(Buffer.from(token.privateKey));
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey: any = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = token.sshCert.trim().split(/\s+/);
|
||||
const certType = certParts[0];
|
||||
privKey.type = certType;
|
||||
const certBlob = Buffer.from(certParts[1], "base64");
|
||||
|
||||
// Replace the internal public SSH blob with the full certificate
|
||||
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||
(s) => String(s) === "Symbol(Public key SSH)",
|
||||
);
|
||||
if (!pubSSHSym) {
|
||||
throw new Error(
|
||||
"Cannot find public SSH symbol on parsed key, ssh2 internals may have changed",
|
||||
);
|
||||
}
|
||||
privKey[pubSSHSym] = certBlob;
|
||||
|
||||
// Wrap sign() for ECDSA cert keys
|
||||
if (privKey.type.startsWith("ecdsa-")) {
|
||||
const origSign = privKey.sign.bind(privKey);
|
||||
privKey.sign = (data: Buffer, algo?: string) => {
|
||||
const sigAlgo = algo?.includes("-cert-")
|
||||
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
|
||||
: algo;
|
||||
const sig = origSign(data, sigAlgo);
|
||||
if (sig instanceof Error || sig[0] !== 0x30) return sig;
|
||||
// Convert DER-encoded ECDSA signature to SSH wire format
|
||||
try {
|
||||
let pos = 2;
|
||||
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
|
||||
pos++;
|
||||
const rLen = sig[pos++];
|
||||
const r = sig.subarray(pos, pos + rLen);
|
||||
pos += rLen + 1;
|
||||
const sLen = sig[pos++];
|
||||
const s = sig.subarray(pos, pos + sLen);
|
||||
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
|
||||
out.writeUInt32BE(r.length, 0);
|
||||
r.copy(out, 4);
|
||||
out.writeUInt32BE(s.length, 4 + r.length);
|
||||
s.copy(out, 4 + r.length + 4);
|
||||
return out;
|
||||
} catch {
|
||||
return sig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set up authHandler to bypass ssh2's cert type rejection
|
||||
let certAuthAttempted = false;
|
||||
config.authHandler = (
|
||||
methodsLeft: string[],
|
||||
_partialSuccess: boolean,
|
||||
callback: (authInfo: any) => void,
|
||||
) => {
|
||||
if (
|
||||
!certAuthAttempted &&
|
||||
(!methodsLeft || methodsLeft.includes("publickey"))
|
||||
) {
|
||||
certAuthAttempted = true;
|
||||
callback({ type: "publickey", username, key: privKey });
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Monkey-patch Protocol.authPK after connect() to fix the signature
|
||||
// wrapper algorithm for cert types.
|
||||
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
|
||||
const origConnect = client.connect.bind(client);
|
||||
(client as any).connect = (cfg: any) => {
|
||||
origConnect(cfg);
|
||||
const proto = (client as any)._protocol;
|
||||
if (!proto) return;
|
||||
const origAuthPK = proto.authPK.bind(proto);
|
||||
proto.authPK = (
|
||||
user: string,
|
||||
pubKey: any,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: Function,
|
||||
) => {
|
||||
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
|
||||
if (!isCertAuth) {
|
||||
return origAuthPK(user, pubKey, keyAlgo, cbSign);
|
||||
}
|
||||
|
||||
// Signed auth with cert type: rebuild packet with base algo in
|
||||
// the signature wrapper. keyAlgo may be undefined for ECDSA.
|
||||
const certAlgo = keyAlgo || pubKey.type;
|
||||
const pubSSH = pubKey.getPublicSSH();
|
||||
const sessionID = proto._kex.sessionID;
|
||||
const sesLen = sessionID.length;
|
||||
const userLen = Buffer.byteLength(user);
|
||||
const certAlgoLen = Buffer.byteLength(certAlgo);
|
||||
const baseAlgoLen = Buffer.byteLength(baseAlgo);
|
||||
const pubKeyLen = pubSSH.length;
|
||||
|
||||
// Build data to sign (uses cert algo — matches server verification)
|
||||
const sigDataLen =
|
||||
4 +
|
||||
sesLen +
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen;
|
||||
const sigData = Buffer.allocUnsafe(sigDataLen);
|
||||
let sp = 0;
|
||||
sigData.writeUInt32BE(sesLen, sp);
|
||||
sp += 4;
|
||||
sessionID.copy(sigData, sp);
|
||||
sp += sesLen;
|
||||
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
sigData.writeUInt32BE(userLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(user, sp, userLen, "utf8");
|
||||
sp += userLen;
|
||||
sigData.writeUInt32BE(14, sp);
|
||||
sp += 4;
|
||||
sigData.write("ssh-connection", sp, 14, "utf8");
|
||||
sp += 14;
|
||||
sigData.writeUInt32BE(9, sp);
|
||||
sp += 4;
|
||||
sigData.write("publickey", sp, 9, "utf8");
|
||||
sp += 9;
|
||||
sigData[sp++] = 1; // TRUE
|
||||
sigData.writeUInt32BE(certAlgoLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
|
||||
sp += certAlgoLen;
|
||||
sigData.writeUInt32BE(pubKeyLen, sp);
|
||||
sp += 4;
|
||||
pubSSH.copy(sigData, sp);
|
||||
|
||||
cbSign(sigData, (signature: Buffer) => {
|
||||
const sigLen = signature.length;
|
||||
const payloadLen =
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen +
|
||||
4 +
|
||||
4 +
|
||||
baseAlgoLen +
|
||||
4 +
|
||||
sigLen;
|
||||
const packet = proto._packetRW.write.alloc(payloadLen);
|
||||
let pp = proto._packetRW.write.allocStart;
|
||||
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
packet.writeUInt32BE(userLen, ++pp);
|
||||
pp += 4;
|
||||
packet.write(user, pp, userLen, "utf8");
|
||||
pp += userLen;
|
||||
packet.writeUInt32BE(14, pp);
|
||||
pp += 4;
|
||||
packet.write("ssh-connection", pp, 14, "utf8");
|
||||
pp += 14;
|
||||
packet.writeUInt32BE(9, pp);
|
||||
pp += 4;
|
||||
packet.write("publickey", pp, 9, "utf8");
|
||||
pp += 9;
|
||||
packet[pp++] = 1; // TRUE
|
||||
// Header: cert type
|
||||
packet.writeUInt32BE(certAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(certAlgo, pp, certAlgoLen, "utf8");
|
||||
pp += certAlgoLen;
|
||||
// Public key blob
|
||||
packet.writeUInt32BE(pubKeyLen, pp);
|
||||
pp += 4;
|
||||
pubSSH.copy(packet, pp);
|
||||
pp += pubKeyLen;
|
||||
// Signature wrapper: base algo (NOT cert type)
|
||||
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
|
||||
pp += 4;
|
||||
packet.writeUInt32BE(baseAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
|
||||
pp += baseAlgoLen;
|
||||
packet.writeUInt32BE(sigLen, pp);
|
||||
pp += 4;
|
||||
signature.copy(packet, pp);
|
||||
|
||||
proto._authsQueue.push("publickey");
|
||||
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
|
||||
const finalized = proto._packetRW.write.finalize(packet);
|
||||
proto._cipher.encrypt(finalized);
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Client } from "ssh2";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
interface PooledConnection {
|
||||
client: Client;
|
||||
lastUsed: number;
|
||||
inUse: boolean;
|
||||
hostKey: string;
|
||||
}
|
||||
|
||||
class SSHConnectionPool {
|
||||
private connections = new Map<string, PooledConnection[]>();
|
||||
private maxConnectionsPerHost = 3;
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor() {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanup();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
private isConnectionHealthy(client: Client): boolean {
|
||||
try {
|
||||
const sock = (
|
||||
client as unknown as {
|
||||
_sock?: { destroyed?: boolean; writable?: boolean };
|
||||
}
|
||||
)._sock;
|
||||
if (sock && (sock.destroyed || !sock.writable)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getConnection(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
): Promise<Client> {
|
||||
let connections = this.connections.get(key) || [];
|
||||
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
if (!this.isConnectionHealthy(available.client)) {
|
||||
sshLogger.warn("Removing unhealthy connection from pool", {
|
||||
operation: "pool_remove_dead",
|
||||
hostKey: key,
|
||||
});
|
||||
try {
|
||||
available.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
connections = connections.filter((c) => c !== available);
|
||||
this.connections.set(key, connections);
|
||||
} else {
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
return available.client;
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
const client = await factory();
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
lastUsed: Date.now(),
|
||||
inUse: true,
|
||||
hostKey: key,
|
||||
};
|
||||
connections.push(pooled);
|
||||
this.connections.set(key, connections);
|
||||
|
||||
client.on("end", () => {
|
||||
this.removeConnection(key, client);
|
||||
});
|
||||
client.on("close", () => {
|
||||
this.removeConnection(key, client);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkAvailable = () => {
|
||||
const conns = this.connections.get(key) || [];
|
||||
const avail = conns.find((conn) => !conn.inUse);
|
||||
if (avail) {
|
||||
if (!this.isConnectionHealthy(avail.client)) {
|
||||
try {
|
||||
avail.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
const filtered = conns.filter((c) => c !== avail);
|
||||
this.connections.set(key, filtered);
|
||||
factory().then((client) => {
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
lastUsed: Date.now(),
|
||||
inUse: true,
|
||||
hostKey: key,
|
||||
};
|
||||
filtered.push(pooled);
|
||||
this.connections.set(key, filtered);
|
||||
client.on("end", () => this.removeConnection(key, client));
|
||||
client.on("close", () => this.removeConnection(key, client));
|
||||
resolve(client);
|
||||
});
|
||||
} else {
|
||||
avail.inUse = true;
|
||||
avail.lastUsed = Date.now();
|
||||
resolve(avail.client);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAvailable, 100);
|
||||
}
|
||||
};
|
||||
checkAvailable();
|
||||
});
|
||||
}
|
||||
|
||||
releaseConnection(key: string, client: Client): void {
|
||||
const connections = this.connections.get(key) || [];
|
||||
const pooled = connections.find((conn) => conn.client === client);
|
||||
if (pooled) {
|
||||
pooled.inUse = false;
|
||||
pooled.lastUsed = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private removeConnection(key: string, client: Client): void {
|
||||
const connections = this.connections.get(key);
|
||||
if (!connections) return;
|
||||
const filtered = connections.filter((c) => c.client !== client);
|
||||
if (filtered.length === 0) {
|
||||
this.connections.delete(key);
|
||||
} else {
|
||||
this.connections.set(key, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
clearKeyConnections(key: string): void {
|
||||
const connections = this.connections.get(key) || [];
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
this.connections.delete(key);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 10 * 60 * 1000;
|
||||
|
||||
for (const [hostKey, connections] of this.connections.entries()) {
|
||||
const activeConnections = connections.filter((conn) => {
|
||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!this.isConnectionHealthy(conn.client)) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (activeConnections.length === 0) {
|
||||
this.connections.delete(hostKey);
|
||||
} else {
|
||||
this.connections.set(hostKey, activeConnections);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAllConnections(): void {
|
||||
for (const connections of this.connections.values()) {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.clearAllConnections();
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionPool = new SSHConnectionPool();
|
||||
|
||||
export async function withConnection<T>(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await connectionPool.getConnection(key, factory);
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
connectionPool.releaseConnection(key, client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { type Client, type ClientChannel } from "ssh2";
|
||||
import { WebSocket } from "ws";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
|
||||
const MAX_BUFFER_BYTES = 512 * 1024;
|
||||
const DEFAULT_TIMEOUT_MINUTES = 30;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||
const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
tabInstanceId?: string;
|
||||
attachedTabInstanceId?: string;
|
||||
|
||||
sshConn: Client | null;
|
||||
sshStream: ClientChannel | null;
|
||||
jumpClient: Client | null;
|
||||
|
||||
cols: number;
|
||||
rows: number;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
|
||||
attachedWs: WebSocket | null;
|
||||
lastDetachedAt: number | null;
|
||||
detachTimeout: NodeJS.Timeout | null;
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
tmuxSessionName: string | null;
|
||||
}
|
||||
|
||||
class TerminalSessionManager {
|
||||
private static instance: TerminalSessionManager;
|
||||
private sessions = new Map<string, TerminalSession>();
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.healthCheckTimer = setInterval(
|
||||
() => this.healthCheck(),
|
||||
HEALTH_CHECK_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): TerminalSessionManager {
|
||||
if (!TerminalSessionManager.instance) {
|
||||
TerminalSessionManager.instance = new TerminalSessionManager();
|
||||
}
|
||||
return TerminalSessionManager.instance;
|
||||
}
|
||||
|
||||
createSession(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
tabInstanceId?: string,
|
||||
): string {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||
const detached = userSessions
|
||||
.filter((s) => s.attachedWs === null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.lastDetachedAt ?? a.createdAt) -
|
||||
(b.lastDetachedAt ?? b.createdAt),
|
||||
);
|
||||
if (detached.length > 0) {
|
||||
this.destroySession(detached[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
if (tabInstanceId) {
|
||||
const tabSessions = userSessions.filter(
|
||||
(s) => s.tabInstanceId === tabInstanceId,
|
||||
);
|
||||
if (tabSessions.length > 0) {
|
||||
sshLogger.warn("Tab instance already has session, destroying old", {
|
||||
operation: "session_tab_duplicate_cleanup",
|
||||
existingSessionId: tabSessions[0].id,
|
||||
tabInstanceId,
|
||||
});
|
||||
this.destroySession(tabSessions[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
userId,
|
||||
hostId,
|
||||
hostName,
|
||||
tabInstanceId,
|
||||
sshConn: null,
|
||||
sshStream: null,
|
||||
jumpClient: null,
|
||||
cols,
|
||||
rows,
|
||||
isConnected: false,
|
||||
createdAt: Date.now(),
|
||||
attachedWs: null,
|
||||
lastDetachedAt: null,
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
tmuxSessionName: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
sshLogger.info("Terminal session created", {
|
||||
operation: "session_created",
|
||||
sessionId: id,
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
getSession(sessionId: string | null): TerminalSession | null {
|
||||
if (!sessionId) return null;
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
setSSHState(
|
||||
sessionId: string,
|
||||
conn: Client,
|
||||
stream: ClientChannel,
|
||||
jumpClient?: Client | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.sshConn = conn;
|
||||
session.sshStream = stream;
|
||||
session.jumpClient = jumpClient ?? null;
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
attachWs(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
ws: WebSocket,
|
||||
tabInstanceId?: string,
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
sshLogger.warn("Session not found for attachment", {
|
||||
operation: "session_attach_not_found",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
sshLogger.warn("Session userId mismatch", {
|
||||
operation: "session_attach_user_mismatch",
|
||||
sessionId,
|
||||
expectedUserId: session.userId,
|
||||
providedUserId: userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!session.isConnected) {
|
||||
sshLogger.warn("Session not connected", {
|
||||
operation: "session_attach_not_connected",
|
||||
sessionId,
|
||||
userId,
|
||||
createdAt: session.createdAt,
|
||||
elapsed: Date.now() - session.createdAt,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDetached =
|
||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab = session.tabInstanceId === tabInstanceId;
|
||||
|
||||
if (
|
||||
!isDetached &&
|
||||
!isOriginalTab &&
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId
|
||||
) {
|
||||
sshLogger.warn("Session actively attached to different tab instance", {
|
||||
operation: "session_attach_instance_conflict",
|
||||
sessionId,
|
||||
sessionInstanceId: session.tabInstanceId,
|
||||
providedInstanceId: tabInstanceId,
|
||||
});
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session belongs to a different tab instance",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
session.tabInstanceId &&
|
||||
tabInstanceId &&
|
||||
session.tabInstanceId !== tabInstanceId
|
||||
) {
|
||||
sshLogger.info(
|
||||
"Session attached to different tab instance (split-screen)",
|
||||
{
|
||||
operation: "session_attach_split_screen",
|
||||
originalInstanceId: session.tabInstanceId,
|
||||
newInstanceId: tabInstanceId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (session.attachedWs && session.attachedWs !== ws) {
|
||||
try {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "sessionTakenOver",
|
||||
sessionId,
|
||||
message: "Session was attached from another tab",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.attachedWs = null;
|
||||
}
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = ws;
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
|
||||
sshLogger.info("WebSocket attached to session", {
|
||||
operation: "session_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
tabInstanceId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = null;
|
||||
session.lastDetachedAt = Date.now();
|
||||
|
||||
const timeoutMs = this.getTimeoutMs();
|
||||
|
||||
session.detachTimeout = setTimeout(() => {
|
||||
sshLogger.info("Session idle timeout expired", {
|
||||
operation: "session_idle_timeout",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
});
|
||||
this.destroySession(sessionId);
|
||||
}, timeoutMs);
|
||||
|
||||
sshLogger.info("WebSocket detached from session", {
|
||||
operation: "session_detach",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
timeoutMinutes: timeoutMs / 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
destroySession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
if (session.detachTimeout) {
|
||||
clearTimeout(session.detachTimeout);
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
session.sshStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshStream = null;
|
||||
}
|
||||
|
||||
if (session.sshConn) {
|
||||
try {
|
||||
session.sshConn.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.sshConn = null;
|
||||
}
|
||||
|
||||
if (session.jumpClient) {
|
||||
try {
|
||||
session.jumpClient.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.jumpClient = null;
|
||||
}
|
||||
|
||||
session.isConnected = false;
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
sshLogger.info("Terminal session destroyed", {
|
||||
operation: "session_destroyed",
|
||||
sessionId,
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
}
|
||||
|
||||
getUserSessions(userId: string): TerminalSession[] {
|
||||
const result: TerminalSession[] = [];
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.userId === userId) {
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bufferOutput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.outputBuffer.push(data);
|
||||
session.outputBufferBytes += data.length;
|
||||
|
||||
while (
|
||||
session.outputBufferBytes > MAX_BUFFER_BYTES &&
|
||||
session.outputBuffer.length > 0
|
||||
) {
|
||||
const removed = session.outputBuffer.shift();
|
||||
if (removed) session.outputBufferBytes -= removed.length;
|
||||
}
|
||||
}
|
||||
|
||||
flushBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
const data = session.outputBuffer.join("");
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
getBuffer(session: TerminalSession): string | null {
|
||||
if (session.outputBuffer.length === 0) return null;
|
||||
return session.outputBuffer.join("");
|
||||
}
|
||||
|
||||
private getTimeoutMs(): number {
|
||||
try {
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
if (row) {
|
||||
const minutes = parseInt(row.value, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
return minutes * 60_000;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB not available, use default
|
||||
}
|
||||
return DEFAULT_TIMEOUT_MINUTES * 60_000;
|
||||
}
|
||||
|
||||
private healthCheck(): void {
|
||||
const toDestroy: string[] = [];
|
||||
const now = Date.now();
|
||||
const GRACE_PERIOD_MS = 10_000;
|
||||
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (!session.isConnected) continue;
|
||||
|
||||
if (
|
||||
session.attachedWs &&
|
||||
session.attachedWs.readyState === WebSocket.OPEN
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session.sshStream?.destroyed) {
|
||||
const detachedDuration = session.lastDetachedAt
|
||||
? now - session.lastDetachedAt
|
||||
: 0;
|
||||
|
||||
if (detachedDuration > GRACE_PERIOD_MS) {
|
||||
sshLogger.info(
|
||||
"SSH stream destroyed during detach window, cleaning up",
|
||||
{
|
||||
operation: "session_health_check_stream_destroyed",
|
||||
sessionId: id,
|
||||
userId: session.userId,
|
||||
detachedFor: detachedDuration,
|
||||
},
|
||||
);
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!session.sshConn) {
|
||||
toDestroy.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of toDestroy) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
}
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = TerminalSessionManager.getInstance();
|
||||