Compare commits
33 Commits
main
...
starhound/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
247c1b5c0a | ||
|
|
776f581377 | ||
|
|
3ac7ad0bd7 | ||
|
|
bc6264bb50 | ||
|
|
5d61112a4e | ||
|
|
d047beab13 | ||
|
|
f2285b1abb | ||
|
|
48933e9b11 | ||
|
|
3248b2336b | ||
|
|
4b4bff4b29 | ||
|
|
2f092bd367 | ||
|
|
42e27e7389 | ||
|
|
aa1476fc13 | ||
|
|
7c9762562b | ||
|
|
a84eb5636e | ||
|
|
65466bc3f9 | ||
|
|
208110a433 | ||
|
|
a98359ebc1 | ||
|
|
05a1b3bfaf | ||
|
|
dfb9e7afe7 | ||
|
|
e405f8a6fa | ||
|
|
f8de3369c3 | ||
|
|
150d5796f8 | ||
|
|
18f31ade1e | ||
|
|
4863776f9b | ||
|
|
84c7b9f9fc | ||
|
|
2754585988 | ||
|
|
a06e62b81a | ||
|
|
69dfebab37 | ||
|
|
4da2b985ad | ||
|
|
b57cc52c94 | ||
|
|
757d0c246d | ||
|
|
7975a077ea |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
31
.github/workflows/electron.yml
vendored
31
.github/workflows/electron.yml
vendored
@@ -356,7 +356,7 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit'
|
||||
if: github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -545,7 +545,7 @@ jobs:
|
||||
CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')
|
||||
|
||||
mkdir -p homebrew-generated
|
||||
cp Casks/termix.rb homebrew-generated/termix.rb
|
||||
cp homebrew/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
|
||||
@@ -584,7 +584,7 @@ jobs:
|
||||
|
||||
submit-to-chocolatey:
|
||||
runs-on: windows-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '')
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -689,7 +689,7 @@ jobs:
|
||||
|
||||
submit-to-flatpak:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '')
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -776,7 +776,7 @@ jobs:
|
||||
|
||||
submit-to-homebrew:
|
||||
runs-on: macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -801,20 +801,11 @@ jobs:
|
||||
URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"
|
||||
|
||||
mkdir -p release_asset
|
||||
DOWNLOAD_PATH="release_asset/$DMG_NAME"
|
||||
PATH="release_asset/$DMG_NAME"
|
||||
echo "Downloading DMG from $URL"
|
||||
curl -L -o "$PATH" "$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}')
|
||||
CHECKSUM=$(shasum -a 256 "$PATH" | awk '{print $1}')
|
||||
|
||||
echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
@@ -827,7 +818,7 @@ jobs:
|
||||
|
||||
mkdir -p homebrew-submission/Casks/t
|
||||
|
||||
cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
|
||||
sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb
|
||||
@@ -881,7 +872,7 @@ jobs:
|
||||
|
||||
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')
|
||||
if: github.event.inputs.artifact_destination == 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -986,7 +977,7 @@ jobs:
|
||||
- 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)
|
||||
PKG_FILE=$(find artifact-mas -name "*.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
echo "PKG file not found, exiting."
|
||||
exit 1
|
||||
|
||||
437
.github/workflows/translate.yml
vendored
437
.github/workflows/translate.yml
vendored
@@ -1,437 +0,0 @@
|
||||
name: Auto Translate
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
translate-zh:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t zh --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-zh
|
||||
path: src/locales/zh.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-ru:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ru --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-ru
|
||||
path: src/locales/ru.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-pt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pt --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-pt
|
||||
path: src/locales/pt.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-fr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t fr --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-fr
|
||||
path: src/locales/fr.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-es:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t es --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-es
|
||||
path: src/locales/es.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-de:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t de --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-de
|
||||
path: src/locales/de.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-hi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t hi --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-hi
|
||||
path: src/locales/hi.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-bn:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t bn --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-bn
|
||||
path: src/locales/bn.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-ja:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ja --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-ja
|
||||
path: src/locales/ja.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-vi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t vi --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-vi
|
||||
path: src/locales/vi.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-tr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t tr --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-tr
|
||||
path: src/locales/tr.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-ko:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ko --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-ko
|
||||
path: src/locales/ko.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-it:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t it --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-it
|
||||
path: src/locales/it.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-he:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t he --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-he
|
||||
path: src/locales/he.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-ar:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ar --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-ar
|
||||
path: src/locales/ar.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-pl:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t pl --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-pl
|
||||
path: src/locales/pl.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-nl:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nl --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-nl
|
||||
path: src/locales/nl.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-sv:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t sv --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-sv
|
||||
path: src/locales/sv.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-id:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t id --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-id
|
||||
path: src/locales/id.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-th:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t th --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-th
|
||||
path: src/locales/th.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-uk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t uk --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-uk
|
||||
path: src/locales/uk.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-cs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t cs --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-cs
|
||||
path: src/locales/cs.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-ro:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t ro --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-ro
|
||||
path: src/locales/ro.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-el:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t el --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-el
|
||||
path: src/locales/el.json
|
||||
continue-on-error: true
|
||||
|
||||
translate-nb:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: npx i18n-auto-translation -k ${{ secrets.GOOGLE_TRANSLATE_API_KEY }} -d "src/locales" -f en -t nb --maxLinesPerRequest 1
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: translations-nb
|
||||
path: src/locales/nb.json
|
||||
continue-on-error: true
|
||||
|
||||
create-pr:
|
||||
needs:
|
||||
[
|
||||
translate-zh,
|
||||
translate-ru,
|
||||
translate-pt,
|
||||
translate-fr,
|
||||
translate-es,
|
||||
translate-de,
|
||||
translate-hi,
|
||||
translate-bn,
|
||||
translate-ja,
|
||||
translate-vi,
|
||||
translate-tr,
|
||||
translate-ko,
|
||||
translate-it,
|
||||
translate-he,
|
||||
translate-ar,
|
||||
translate-pl,
|
||||
translate-nl,
|
||||
translate-sv,
|
||||
translate-id,
|
||||
translate-th,
|
||||
translate-uk,
|
||||
translate-cs,
|
||||
translate-ro,
|
||||
translate-el,
|
||||
translate-nb,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: translations-temp
|
||||
|
||||
- name: Move translations to src/locales
|
||||
run: |
|
||||
cp translations-temp/translations-zh/zh.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-ru/ru.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-pt/pt.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-fr/fr.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-es/es.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-de/de.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-hi/hi.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-bn/bn.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-ja/ja.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-vi/vi.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-tr/tr.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-ko/ko.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-it/it.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-he/he.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-ar/ar.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-pl/pl.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-nl/nl.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-sv/sv.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-id/id.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-th/th.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-uk/uk.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-cs/cs.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-ro/ro.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-el/el.json src/locales/ 2>/dev/null || true
|
||||
cp translations-temp/translations-nb/nb.json src/locales/ 2>/dev/null || true
|
||||
rm -rf translations-temp
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
commit-message: "chore: auto-translate to multiple languages"
|
||||
branch: translations-update
|
||||
delete-branch: true
|
||||
title: "chore: Update translations for all languages"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,4 +28,3 @@ dist-ssr
|
||||
/.mcp.json
|
||||
/nul
|
||||
/.vscode/
|
||||
/CLAUDE.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "1.10.0"
|
||||
sha256 "327c5026006c949f992447835aa6754113f731065b410bedbfa5da5af7cb2386"
|
||||
version "1.9.0"
|
||||
sha256 "8fedd242b3cae1ebfd0c391a36f1c246a26ecac258b02478ee8dea2f33cd6d96"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
16
README-CN.md
16
README-CN.md
@@ -51,22 +51,20 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
|
||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能
|
||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件
|
||||
- **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们。
|
||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||
- **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
|
||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||
- **RBAC** - 创建角色并在用户/角色之间共享主机
|
||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。
|
||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。
|
||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。
|
||||
- **语言** - 内置支持约 30 种语言(通过 Google 翻译批量翻译,结果可能有所不同)
|
||||
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面
|
||||
- **语言** - 内置支持英语、中文、德语和葡萄牙语
|
||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
|
||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
|
||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||
- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接、SOCKS5、密码自动填充等。
|
||||
- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。
|
||||
|
||||
# 计划功能
|
||||
|
||||
@@ -142,12 +140,6 @@ volumes:
|
||||
|
||||
<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 110"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -155,7 +147,7 @@ volumes:
|
||||
你的浏览器不支持 video 标签。
|
||||
</video>
|
||||
</p>
|
||||
某些视频和图像可能已过时或可能无法完美展示功能。
|
||||
视频和图像可能已过时。
|
||||
|
||||
# 许可证
|
||||
|
||||
|
||||
22
README.md
22
README.md
@@ -45,7 +45,7 @@ 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, remote file management, and many other tools. Termix is the perfect
|
||||
access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect
|
||||
free and self-hosted alternative to Termius available for all platforms.
|
||||
|
||||
# Features
|
||||
@@ -53,22 +53,20 @@ free and self-hosted alternative to Termius available for all platforms.
|
||||
- **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
|
||||
- **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
|
||||
- **Dashboard** - View server information at a glance on your dashboard
|
||||
- **RBAC** - Create roles and share hosts across users/roles
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
|
||||
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI.
|
||||
- **Languages** - Built-in support ~30 languages (bulk translated via Google Translate, results may vary ofc)
|
||||
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
|
||||
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
|
||||
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, SOCKS5, password autofill, etc.
|
||||
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
|
||||
|
||||
# Planned Features
|
||||
|
||||
@@ -84,7 +82,7 @@ Supported Devices:
|
||||
- MSI Installer
|
||||
- Chocolatey Package Manager
|
||||
- Linux (x64/ia32)
|
||||
- Portable [(AUR available)](https://aur.archlinux.org/packages/termix-bin)
|
||||
- Portable
|
||||
- AppImage
|
||||
- Deb
|
||||
- Flatpak
|
||||
@@ -126,7 +124,7 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht
|
||||
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
|
||||
channel, however, response times may be longer.
|
||||
|
||||
# Screenshots
|
||||
# Show-off
|
||||
|
||||
<p align="center">
|
||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||
@@ -145,12 +143,6 @@ 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">
|
||||
<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 110"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -158,7 +150,7 @@ channel, however, response times may be longer.
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
Some videos and images may be out of date or may not perfectly showcase features.
|
||||
Videos and images may be out of date.
|
||||
|
||||
# License
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
files:
|
||||
- source: /src/locales/en.json
|
||||
translation: /src/locales/translated/%two_letters_code%.json
|
||||
@@ -19,7 +19,7 @@ COPY . .
|
||||
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
|
||||
|
||||
RUN npm cache clean --force && \
|
||||
NODE_OPTIONS="--max-old-space-size=3072" npm run build
|
||||
npm run build
|
||||
|
||||
# Stage 3: Build backend
|
||||
FROM deps AS backend-builder
|
||||
@@ -53,18 +53,16 @@ ENV DATA_DIR=/app/data \
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \
|
||||
chown -R node:node /app && \
|
||||
chmod 755 /app/data /app/uploads /app/nginx && \
|
||||
touch /app/nginx/nginx.conf && \
|
||||
chown node:node /app/nginx/nginx.conf
|
||||
mkdir -p /app/data /app/uploads && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
useradd -r -s /bin/false nginx
|
||||
|
||||
COPY docker/nginx.conf /app/nginx/nginx.conf.template
|
||||
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf
|
||||
|
||||
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=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=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
@@ -76,7 +74,4 @@ EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
49
docker/docker-compose.yml
Normal file
49
docker/docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
termix:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: termix
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- termix_data:/app/db/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GUACD_HOST=guacd
|
||||
- GUACD_PORT=4822
|
||||
- ENABLE_GUACAMOLE=true
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
- termix-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:latest
|
||||
container_name: termix-guacd
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- termix-network
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "4822"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
termix-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
termix_data:
|
||||
driver: local
|
||||
|
||||
@@ -11,21 +11,24 @@ echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "SSL enabled - using HTTPS configuration with redirect"
|
||||
NGINX_CONF_SOURCE="/app/nginx/nginx-https.conf.template"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx-https.conf"
|
||||
else
|
||||
echo "SSL disabled - using HTTP-only configuration (default)"
|
||||
NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template"
|
||||
NGINX_CONF_SOURCE="/etc/nginx/nginx.conf"
|
||||
fi
|
||||
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /app/nginx/nginx.conf
|
||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /etc/nginx/nginx.conf.tmp
|
||||
mv /etc/nginx/nginx.conf.tmp /etc/nginx/nginx.conf
|
||||
|
||||
mkdir -p /app/data /app/uploads
|
||||
chmod 755 /app/data /app/uploads 2>/dev/null || true
|
||||
chown -R node:node /app/data /app/uploads
|
||||
chmod 755 /app/data /app/uploads
|
||||
|
||||
if [ "$ENABLE_SSL" = "true" ]; then
|
||||
echo "Checking SSL certificate configuration..."
|
||||
mkdir -p /app/data/ssl
|
||||
chmod 755 /app/data/ssl 2>/dev/null || true
|
||||
chown -R node:node /app/data/ssl
|
||||
chmod 755 /app/data/ssl
|
||||
|
||||
DOMAIN=${SSL_DOMAIN:-localhost}
|
||||
|
||||
@@ -81,6 +84,7 @@ 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
|
||||
|
||||
@@ -89,7 +93,7 @@ EOF
|
||||
fi
|
||||
|
||||
echo "Starting nginx..."
|
||||
nginx -c /app/nginx/nginx.conf
|
||||
nginx
|
||||
|
||||
echo "Starting backend services..."
|
||||
cd /app
|
||||
@@ -106,7 +110,11 @@ else
|
||||
echo "Warning: package.json not found"
|
||||
fi
|
||||
|
||||
node dist/backend/backend/starter.js
|
||||
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
|
||||
|
||||
echo "All services started"
|
||||
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
include 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;
|
||||
@@ -48,17 +37,9 @@ 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 /app/html;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.map$ {
|
||||
@@ -112,15 +93,6 @@ http {
|
||||
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 $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -231,6 +203,41 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole WebSocket for RDP/VNC/Telnet
|
||||
# ^~ modifier ensures this takes precedence over the regex location below
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30007/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole REST API
|
||||
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 /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
@@ -311,10 +318,6 @@ http {
|
||||
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 ~ ^/uptime(/.*)?$ {
|
||||
@@ -335,45 +338,9 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
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 $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
include 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;
|
||||
@@ -38,14 +27,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 /app/html;
|
||||
root /usr/share/nginx/html;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /app/html;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
@@ -101,15 +90,6 @@ http {
|
||||
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 $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -220,6 +200,41 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole WebSocket for RDP/VNC/Telnet
|
||||
# ^~ modifier ensures this takes precedence over the regex location below
|
||||
location ^~ /guacamole/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30007/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# Guacamole REST API
|
||||
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 /ssh/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
@@ -300,10 +315,6 @@ http {
|
||||
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 ~ ^/uptime(/.*)?$ {
|
||||
@@ -324,45 +335,9 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
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 $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
if (process.platform === "linux") {
|
||||
// Enable Ozone platform auto-detection for Wayland/X11 support
|
||||
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
||||
|
||||
// Enable hardware video decoding if available
|
||||
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
|
||||
|
||||
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
|
||||
saveServerConfig: (config) =>
|
||||
ipcRenderer.invoke("save-server-config", config),
|
||||
testServerConnection: (serverUrl) =>
|
||||
ipcRenderer.invoke("test-server-connection", serverUrl),
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
|
||||
onUpdateDownloaded: (callback) =>
|
||||
ipcRenderer.on("update-downloaded", callback),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
|
||||
isElectron: true,
|
||||
|
||||
1136
package-lock.json
generated
1136
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.9.0",
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -35,7 +35,6 @@
|
||||
"@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",
|
||||
@@ -46,19 +45,19 @@
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/guacamole-common-js": "^1.5.5",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@uiw/codemirror-extensions-langs": "^4.24.1",
|
||||
"@uiw/codemirror-theme-github": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.24.1",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -78,7 +77,8 @@
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.44.3",
|
||||
"express": "^5.1.0",
|
||||
"i18n-auto-translation": "^2.2.3",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jose": "^5.2.3",
|
||||
@@ -106,7 +106,6 @@
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"socks": "^2.8.7",
|
||||
"sonner": "^2.0.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.16.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 407 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
@@ -2,8 +2,8 @@ import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import { recentActivity, sshData, hostAccess } from "./database/db/schema.js";
|
||||
import { eq, and, desc, or } from "drizzle-orm";
|
||||
import { recentActivity, sshData } from "./database/db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
@@ -15,7 +15,7 @@ const authManager = AuthManager.getInstance();
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
@@ -127,18 +127,9 @@ app.post("/activity/log", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
![
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"server_stats",
|
||||
"tunnel",
|
||||
"docker",
|
||||
].includes(type)
|
||||
) {
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'",
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +155,7 @@ app.post("/activity/log", async (req, res) => {
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
const ownedHosts = await SimpleDBOps.select(
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
@@ -173,19 +164,8 @@ app.post("/activity/log", async (req, res) => {
|
||||
userId,
|
||||
);
|
||||
|
||||
if (ownedHosts.length === 0) {
|
||||
const sharedHosts = await getDb()
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
);
|
||||
|
||||
if (sharedHosts.length === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Host not found or access denied" });
|
||||
}
|
||||
if (hosts.length === 0) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 rbacRoutes from "./routes/rbac.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
@@ -1437,7 +1437,7 @@ app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
@@ -210,12 +210,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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
|
||||
@@ -336,81 +330,6 @@ 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 {
|
||||
@@ -576,23 +495,17 @@ const migrateSchema = () => {
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_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");
|
||||
// Connection type columns for RDP/VNC/Telnet support
|
||||
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_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");
|
||||
|
||||
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");
|
||||
@@ -653,317 +566,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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'];
|
||||
let deletedCount = 0;
|
||||
|
||||
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
|
||||
for (const roleName of unwantedRoleNames) {
|
||||
const result = deleteByName.run(roleName);
|
||||
if (result.changes > 0) {
|
||||
deletedCount += result.changes;
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
const result = deleteOldSystemRole.run(role.name);
|
||||
if (result.changes > 0) {
|
||||
deletedCount += result.changes;
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 (error) {
|
||||
// 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 (error) {
|
||||
// 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",
|
||||
});
|
||||
|
||||
@@ -52,6 +52,8 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Connection type: ssh, rdp, vnc, telnet
|
||||
connectionType: text("connection_type").notNull().default("ssh"),
|
||||
name: text("name"),
|
||||
ip: text("ip").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
@@ -66,7 +68,6 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
key: text("key", { length: 8192 }),
|
||||
key_password: text("key_password"),
|
||||
keyType: text("key_type"),
|
||||
sudoPassword: text("sudo_password"),
|
||||
|
||||
autostartPassword: text("autostart_password"),
|
||||
autostartKey: text("autostart_key", { length: 8192 }),
|
||||
@@ -92,17 +93,15 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
.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"),
|
||||
|
||||
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"),
|
||||
|
||||
// RDP/VNC specific fields
|
||||
domain: text("domain"),
|
||||
security: text("security"),
|
||||
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||
// RDP/VNC extended configuration (stored as JSON)
|
||||
guacamoleConfig: text("guacamole_config"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -185,11 +184,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
key_password: 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")
|
||||
@@ -294,156 +288,3 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.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(() => sshData.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"),
|
||||
});
|
||||
|
||||
@@ -4,12 +4,7 @@ import type {
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
sshCredentials,
|
||||
sshCredentialUsage,
|
||||
sshData,
|
||||
hostAccess,
|
||||
} from "../db/schema.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
@@ -478,14 +473,6 @@ router.put(
|
||||
userId,
|
||||
);
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||
parseInt(id),
|
||||
userId,
|
||||
);
|
||||
|
||||
const credential = updated[0];
|
||||
authLogger.success(
|
||||
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
|
||||
@@ -540,6 +527,8 @@ 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)
|
||||
@@ -566,32 +555,10 @@ router.delete(
|
||||
eq(sshData.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
for (const host of hostsUsingCredential) {
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, host.id))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (revokedShares.length > 0) {
|
||||
authLogger.info(
|
||||
"Auto-revoked host shares due to credential deletion",
|
||||
{
|
||||
operation: "auto_revoke_shares",
|
||||
hostId: host.id,
|
||||
credentialId: parseInt(id),
|
||||
revokedCount: revokedShares.length,
|
||||
reason: "credential_deleted",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
|
||||
// sshCredentialUsage will be automatically deleted by ON DELETE CASCADE
|
||||
// No need for manual deletion
|
||||
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
@@ -1634,7 +1601,10 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
credData,
|
||||
);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
|
||||
@@ -1,850 +0,0 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
hostAccess,
|
||||
sshData,
|
||||
users,
|
||||
roles,
|
||||
userRoles,
|
||||
auditLogs,
|
||||
sharedCredentials,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
//Share a host with a user or role
|
||||
//POST /rbac/host/:id/share
|
||||
router.post(
|
||||
"/host/:id/share",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user",
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
permissionLevel = "view",
|
||||
} = req.body;
|
||||
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
}
|
||||
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
}
|
||||
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
databaseLogger.warn("Attempt to share host not owned by user", {
|
||||
operation: "share_host",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
if (!host[0].credentialId) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Only hosts using credentials can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetType === "user") {
|
||||
const targetUser = await db
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
} else {
|
||||
const targetRole = await db
|
||||
.select({ id: roles.id, name: roles.name })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, targetRoleId))
|
||||
.limit(1);
|
||||
|
||||
if (targetRole.length === 0) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
const validLevels = ["view"];
|
||||
if (!validLevels.includes(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level. Only 'view' is supported.",
|
||||
validLevels,
|
||||
});
|
||||
}
|
||||
|
||||
const whereConditions = [eq(hostAccess.hostId, hostId)];
|
||||
if (targetType === "user") {
|
||||
whereConditions.push(eq(hostAccess.userId, targetUserId));
|
||||
} else {
|
||||
whereConditions.push(eq(hostAccess.roleId, targetRoleId));
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(...whereConditions))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
})
|
||||
.where(eq(hostAccess.id, existing[0].id));
|
||||
|
||||
await db
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Host access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.insert(hostAccess).values({
|
||||
hostId,
|
||||
userId: targetType === "user" ? targetUserId : null,
|
||||
roleId: targetType === "role" ? targetRoleId : null,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Host shared successfully with ${targetType}`,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share host", error, {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Revoke host access
|
||||
// DELETE /rbac/host/:id/access/:accessId
|
||||
router.delete(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const accessId = parseInt(req.params.accessId, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
|
||||
|
||||
res.json({ success: true, message: "Access revoked" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke host access", error, {
|
||||
operation: "revoke_host_access",
|
||||
hostId,
|
||||
accessId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get host access list
|
||||
// GET /rbac/host/:id/access
|
||||
router.get(
|
||||
"/host/:id/access",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
const rawAccessList = await db
|
||||
.select({
|
||||
id: hostAccess.id,
|
||||
userId: hostAccess.userId,
|
||||
roleId: hostAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
const accessList = rawAccessList.map((access) => ({
|
||||
id: access.id,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
userId: access.userId,
|
||||
roleId: access.roleId,
|
||||
username: access.username,
|
||||
roleName: access.roleName,
|
||||
roleDisplayName: access.roleDisplayName,
|
||||
grantedBy: access.grantedBy,
|
||||
grantedByUsername: access.grantedByUsername,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get host access list", error, {
|
||||
operation: "get_host_access_list",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get access list" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get user's shared hosts (hosts shared WITH this user)
|
||||
// GET /rbac/shared-hosts
|
||||
router.get(
|
||||
"/shared-hosts",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const sharedHosts = await db
|
||||
.select({
|
||||
id: sshData.id,
|
||||
name: sshData.name,
|
||||
ip: sshData.ip,
|
||||
port: sshData.port,
|
||||
username: sshData.username,
|
||||
folder: sshData.folder,
|
||||
tags: sshData.tags,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.innerJoin(users, eq(sshData.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.userId, userId),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
res.json({ sharedHosts });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared hosts", error, {
|
||||
operation: "get_shared_hosts",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get shared hosts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get all roles
|
||||
// GET /rbac/roles
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const allRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
const rolesWithParsedPermissions = allRoles.map((role) => ({
|
||||
...role,
|
||||
permissions: JSON.parse(role.permissions),
|
||||
}));
|
||||
|
||||
res.json({ roles: rolesWithParsedPermissions });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get all roles
|
||||
// GET /rbac/roles
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rolesList = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
displayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
createdAt: roles.createdAt,
|
||||
updatedAt: roles.updatedAt,
|
||||
})
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
res.json({ roles: rolesList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create new role
|
||||
// POST /rbac/roles
|
||||
router.post(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { name, displayName, description } = req.body;
|
||||
|
||||
if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) {
|
||||
return res.status(400).json({
|
||||
error: "Role name and display name are required",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Role name must contain only lowercase letters, numbers, underscores, and hyphens",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, name))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({
|
||||
error: "A role with this name already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await db.insert(roles).values({
|
||||
name,
|
||||
displayName,
|
||||
description: description || null,
|
||||
isSystem: false,
|
||||
permissions: null,
|
||||
});
|
||||
|
||||
const newRoleId = result.lastInsertRowid;
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
roleId: newRoleId,
|
||||
message: "Role created successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create role", error, {
|
||||
operation: "create_role",
|
||||
roleName: name,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update role
|
||||
// PUT /rbac/roles/:id
|
||||
router.put(
|
||||
"/roles/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
const { displayName, description } = req.body;
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
if (!displayName && description === undefined) {
|
||||
return res.status(400).json({
|
||||
error: "At least one field (displayName or description) is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const existingRole = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (existingRole.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
const updates: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
updatedAt: string;
|
||||
} = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (displayName) {
|
||||
updates.displayName = displayName;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.description = description || null;
|
||||
}
|
||||
|
||||
await db.update(roles).set(updates).where(eq(roles.id, roleId));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update role", error, {
|
||||
operation: "update_role",
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete role
|
||||
// DELETE /rbac/roles/:id
|
||||
router.delete(
|
||||
"/roles/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error: "Cannot delete system roles",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedUserRoles = await db
|
||||
.delete(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId))
|
||||
.returning({ userId: userRoles.userId });
|
||||
|
||||
for (const { userId } of deletedUserRoles) {
|
||||
permissionManager.invalidateUserPermissionCache(userId);
|
||||
}
|
||||
|
||||
const deletedHostAccess = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId))
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
await db.delete(roles).where(eq(roles.id, roleId));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete role", error, {
|
||||
operation: "delete_role",
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Assign role to user
|
||||
// POST /rbac/users/:userId/roles
|
||||
router.post(
|
||||
"/users/:userId/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const currentUserId = req.userId!;
|
||||
|
||||
try {
|
||||
const { roleId } = req.body;
|
||||
|
||||
if (typeof roleId !== "number") {
|
||||
return res.status(400).json({ error: "Role ID is required" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const role = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(
|
||||
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "Role already assigned" });
|
||||
}
|
||||
|
||||
await db.insert(userRoles).values({
|
||||
userId: targetUserId,
|
||||
roleId,
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
const hostsSharedWithRole = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
||||
if (ssh_data.credentialId) {
|
||||
try {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
host_access.id,
|
||||
ssh_data.credentialId,
|
||||
targetUserId,
|
||||
ssh_data.userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for new role member",
|
||||
error,
|
||||
{
|
||||
operation: "assign_role_create_credentials",
|
||||
targetUserId,
|
||||
roleId,
|
||||
hostId: ssh_data.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role assigned successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to assign role", error, {
|
||||
operation: "assign_role",
|
||||
targetUserId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to assign role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Remove role from user
|
||||
// DELETE /rbac/users/:userId/roles/:roleId
|
||||
router.delete(
|
||||
"/users/:userId/roles/:roleId",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const roleId = parseInt(req.params.roleId, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const role = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be removed",
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||
);
|
||||
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to remove role", error, {
|
||||
operation: "remove_role",
|
||||
targetUserId,
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to remove role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get user's roles
|
||||
// GET /rbac/users/:userId/roles
|
||||
router.get(
|
||||
"/users/:userId/roles",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const currentUserId = req.userId!;
|
||||
|
||||
if (
|
||||
targetUserId !== currentUserId &&
|
||||
!(await permissionManager.isAdmin(currentUserId))
|
||||
) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRolesList = await db
|
||||
.select({
|
||||
id: userRoles.id,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
grantedAt: userRoles.grantedAt,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, targetUserId));
|
||||
|
||||
res.json({ roles: userRolesList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get user roles", error, {
|
||||
operation: "get_user_roles",
|
||||
targetUserId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get user roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -11,27 +11,13 @@ import {
|
||||
sshFolders,
|
||||
commandHistory,
|
||||
recentActivity,
|
||||
hostAccess,
|
||||
userRoles,
|
||||
sessionRecordings,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
isNotNull,
|
||||
or,
|
||||
isNull,
|
||||
gte,
|
||||
sql,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../db/index.js";
|
||||
@@ -49,7 +35,6 @@ function isValidPort(port: unknown): port is number {
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
@@ -233,6 +218,7 @@ router.post(
|
||||
}
|
||||
|
||||
const {
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
@@ -246,7 +232,6 @@ router.post(
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
sudoPassword,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
@@ -257,18 +242,15 @@ router.post(
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
notes,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
overrideCredentialUsername,
|
||||
// RDP/VNC specific fields
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
} = hostData;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(ip) ||
|
||||
@@ -285,8 +267,10 @@ router.post(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const effectiveConnectionType = connectionType || "ssh";
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name,
|
||||
folder: folder || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
@@ -295,7 +279,6 @@ router.post(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -310,18 +293,14 @@ router.post(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
notes: notes || null,
|
||||
sudoPassword: sudoPassword || null,
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
socks5Host: socks5Host || null,
|
||||
socks5Port: socks5Port || null,
|
||||
socks5Username: socks5Username || null,
|
||||
socks5Password: socks5Password || null,
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
// RDP/VNC specific fields
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -383,10 +362,15 @@ router.post(
|
||||
statsConfig: createdHost.statsConfig
|
||||
? JSON.parse(createdHost.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: createdHost.dockerConfig
|
||||
? JSON.parse(createdHost.dockerConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: createdHost.guacamoleConfig
|
||||
? JSON.parse(createdHost.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host created: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -480,6 +464,7 @@ router.put(
|
||||
}
|
||||
|
||||
const {
|
||||
connectionType,
|
||||
name,
|
||||
folder,
|
||||
tags,
|
||||
@@ -493,7 +478,6 @@ router.put(
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
sudoPassword,
|
||||
pin,
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
@@ -504,18 +488,15 @@ router.put(
|
||||
jumpHosts,
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
notes,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
overrideCredentialUsername,
|
||||
// RDP/VNC specific fields
|
||||
domain,
|
||||
security,
|
||||
ignoreCert,
|
||||
guacamoleConfig,
|
||||
} = hostData;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(ip) ||
|
||||
@@ -535,6 +516,7 @@ router.put(
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
connectionType: connectionType || "ssh",
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
@@ -543,7 +525,6 @@ router.put(
|
||||
username,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
enableTunnel: enableTunnel ? 1 : 0,
|
||||
@@ -558,18 +539,14 @@ router.put(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
notes: notes || null,
|
||||
sudoPassword: sudoPassword || null,
|
||||
useSocks5: useSocks5 ? 1 : 0,
|
||||
socks5Host: socks5Host || null,
|
||||
socks5Port: socks5Port || null,
|
||||
socks5Username: socks5Username || null,
|
||||
socks5Password: socks5Password || null,
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
// RDP/VNC specific fields
|
||||
domain: domain || null,
|
||||
security: security || null,
|
||||
ignoreCert: ignoreCert ? 1 : 0,
|
||||
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -598,100 +575,23 @@ router.put(
|
||||
}
|
||||
|
||||
try {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
Number(hostId),
|
||||
"write",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
sshLogger.warn("User does not have permission to update host", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
if (!accessInfo.isOwner) {
|
||||
sshLogger.warn("Shared user attempted to update host (view-only)", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can modify host configuration",
|
||||
});
|
||||
}
|
||||
|
||||
const hostRecord = await db
|
||||
.select({
|
||||
userId: sshData.userId,
|
||||
credentialId: sshData.credentialId,
|
||||
authType: sshData.authType,
|
||||
})
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, Number(hostId)))
|
||||
.limit(1);
|
||||
|
||||
if (hostRecord.length === 0) {
|
||||
sshLogger.warn("Host not found for update", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
});
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const ownerId = hostRecord[0].userId;
|
||||
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.credentialId !== undefined &&
|
||||
sshDataObj.credentialId !== hostRecord[0].credentialId
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the credential",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!accessInfo.isOwner &&
|
||||
sshDataObj.authType !== undefined &&
|
||||
sshDataObj.authType !== hostRecord[0].authType
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can change the authentication type",
|
||||
});
|
||||
}
|
||||
|
||||
if (sshDataObj.credentialId !== undefined) {
|
||||
if (
|
||||
hostRecord[0].credentialId !== null &&
|
||||
sshDataObj.credentialId === null
|
||||
) {
|
||||
const revokedShares = await db
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, Number(hostId)))
|
||||
.returning({ id: hostAccess.id, userId: hostAccess.userId });
|
||||
}
|
||||
}
|
||||
|
||||
await SimpleDBOps.update(
|
||||
sshData,
|
||||
"ssh_data",
|
||||
eq(sshData.id, Number(hostId)),
|
||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||
sshDataObj,
|
||||
ownerId,
|
||||
userId,
|
||||
);
|
||||
|
||||
const updatedHosts = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(eq(sshData.id, Number(hostId))),
|
||||
.where(
|
||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
||||
),
|
||||
"ssh_data",
|
||||
ownerId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (updatedHosts.length === 0) {
|
||||
@@ -729,10 +629,12 @@ router.put(
|
||||
dockerConfig: updatedHost.dockerConfig
|
||||
? JSON.parse(updatedHost.dockerConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: updatedHost.guacamoleConfig
|
||||
? JSON.parse(updatedHost.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
|
||||
sshLogger.success(
|
||||
`SSH host updated: ${name} (${ip}:${port}) by user ${userId}`,
|
||||
@@ -801,115 +703,11 @@ router.get(
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||
|
||||
const rawData = await db
|
||||
.select({
|
||||
id: sshData.id,
|
||||
userId: sshData.userId,
|
||||
name: sshData.name,
|
||||
ip: sshData.ip,
|
||||
port: sshData.port,
|
||||
username: sshData.username,
|
||||
folder: sshData.folder,
|
||||
tags: sshData.tags,
|
||||
pin: sshData.pin,
|
||||
authType: sshData.authType,
|
||||
password: sshData.password,
|
||||
key: sshData.key,
|
||||
keyPassword: sshData.key_password,
|
||||
keyType: sshData.keyType,
|
||||
enableTerminal: sshData.enableTerminal,
|
||||
enableTunnel: sshData.enableTunnel,
|
||||
tunnelConnections: sshData.tunnelConnections,
|
||||
jumpHosts: sshData.jumpHosts,
|
||||
enableFileManager: sshData.enableFileManager,
|
||||
defaultPath: sshData.defaultPath,
|
||||
autostartPassword: sshData.autostartPassword,
|
||||
autostartKey: sshData.autostartKey,
|
||||
autostartKeyPassword: sshData.autostartKeyPassword,
|
||||
forceKeyboardInteractive: sshData.forceKeyboardInteractive,
|
||||
statsConfig: sshData.statsConfig,
|
||||
terminalConfig: sshData.terminalConfig,
|
||||
createdAt: sshData.createdAt,
|
||||
updatedAt: sshData.updatedAt,
|
||||
credentialId: sshData.credentialId,
|
||||
overrideCredentialUsername: sshData.overrideCredentialUsername,
|
||||
quickActions: sshData.quickActions,
|
||||
notes: sshData.notes,
|
||||
enableDocker: sshData.enableDocker,
|
||||
useSocks5: sshData.useSocks5,
|
||||
socks5Host: sshData.socks5Host,
|
||||
socks5Port: sshData.socks5Port,
|
||||
socks5Username: sshData.socks5Username,
|
||||
socks5Password: sshData.socks5Password,
|
||||
socks5ProxyChain: sshData.socks5ProxyChain,
|
||||
|
||||
ownerId: sshData.userId,
|
||||
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
})
|
||||
.from(sshData)
|
||||
.leftJoin(
|
||||
hostAccess,
|
||||
and(
|
||||
eq(hostAccess.hostId, sshData.id),
|
||||
or(
|
||||
eq(hostAccess.userId, userId),
|
||||
roleIds.length > 0
|
||||
? inArray(hostAccess.roleId, roleIds)
|
||||
: sql`false`,
|
||||
),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(sshData.userId, userId),
|
||||
and(
|
||||
eq(hostAccess.userId, userId),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
roleIds.length > 0
|
||||
? and(
|
||||
inArray(hostAccess.roleId, roleIds),
|
||||
or(
|
||||
isNull(hostAccess.expiresAt),
|
||||
gte(hostAccess.expiresAt, now),
|
||||
),
|
||||
)
|
||||
: sql`false`,
|
||||
),
|
||||
);
|
||||
|
||||
const ownHosts = rawData.filter((row) => row.userId === userId);
|
||||
const sharedHosts = rawData.filter((row) => row.userId !== userId);
|
||||
|
||||
let decryptedOwnHosts: any[] = [];
|
||||
try {
|
||||
decryptedOwnHosts = await SimpleDBOps.select(
|
||||
Promise.resolve(ownHosts),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
} catch (decryptError) {
|
||||
sshLogger.error("Failed to decrypt own hosts", decryptError, {
|
||||
operation: "host_fetch_own_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
decryptedOwnHosts = [];
|
||||
}
|
||||
|
||||
const sanitizedSharedHosts = sharedHosts;
|
||||
|
||||
const data = [...decryptedOwnHosts, ...sanitizedSharedHosts];
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
@@ -936,22 +734,19 @@ router.get(
|
||||
statsConfig: row.statsConfig
|
||||
? JSON.parse(row.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: row.dockerConfig
|
||||
? JSON.parse(row.dockerConfig as string)
|
||||
: undefined,
|
||||
terminalConfig: row.terminalConfig
|
||||
? JSON.parse(row.terminalConfig as string)
|
||||
: undefined,
|
||||
guacamoleConfig: row.guacamoleConfig
|
||||
? JSON.parse(row.guacamoleConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||
socks5ProxyChain: row.socks5ProxyChain
|
||||
? JSON.parse(row.socks5ProxyChain as string)
|
||||
: [],
|
||||
|
||||
isShared: !!row.isShared,
|
||||
permissionLevel: row.permissionLevel || undefined,
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
};
|
||||
|
||||
const resolved =
|
||||
(await resolveHostCredentials(baseHost, userId)) || baseHost;
|
||||
return resolved;
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1023,13 +818,13 @@ router.get(
|
||||
terminalConfig: host.terminalConfig
|
||||
? JSON.parse(host.terminalConfig)
|
||||
: undefined,
|
||||
guacamoleConfig: host.guacamoleConfig
|
||||
? JSON.parse(host.guacamoleConfig)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain)
|
||||
: [],
|
||||
};
|
||||
|
||||
res.json((await resolveHostCredentials(result, userId)) || result);
|
||||
res.json((await resolveHostCredentials(result)) || result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch SSH host by ID from database", err, {
|
||||
operation: "host_fetch_by_id",
|
||||
@@ -1073,7 +868,7 @@ router.get(
|
||||
|
||||
const host = hosts[0];
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
||||
const resolvedHost = (await resolveHostCredentials(host)) || host;
|
||||
|
||||
const exportData = {
|
||||
name: resolvedHost.name,
|
||||
@@ -1098,9 +893,6 @@ router.get(
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||
: [],
|
||||
};
|
||||
|
||||
sshLogger.success("Host exported with decrypted credentials", {
|
||||
@@ -1158,33 +950,57 @@ router.delete(
|
||||
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.hostId, numericHostId),
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.hostId, numericHostId),
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.hostId, numericHostId),
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.hostId, numericHostId),
|
||||
eq(commandHistory.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentialUsage.hostId, numericHostId),
|
||||
eq(sshCredentialUsage.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.hostId, numericHostId));
|
||||
|
||||
await db.delete(hostAccess).where(eq(hostAccess.hostId, numericHostId));
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, numericHostId));
|
||||
.where(
|
||||
and(
|
||||
eq(recentActivity.hostId, numericHostId),
|
||||
eq(recentActivity.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(sshData)
|
||||
@@ -1691,54 +1507,11 @@ router.delete(
|
||||
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
requestingUserId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
if (host.credentialId && (host.userId || host.ownerId)) {
|
||||
if (host.credentialId && host.userId) {
|
||||
const credentialId = host.credentialId as number;
|
||||
const ownerId = (host.ownerId || host.userId) as string;
|
||||
|
||||
if (requestingUserId && requestingUserId !== ownerId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
requestingUserId,
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
...host,
|
||||
authType: sharedCred.authType,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
};
|
||||
|
||||
if (!host.overrideCredentialUsername) {
|
||||
resolvedHost.username = sharedCred.username;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
}
|
||||
} catch (sharedCredError) {
|
||||
sshLogger.warn(
|
||||
"Failed to get shared credential, falling back to owner credential",
|
||||
{
|
||||
operation: "resolve_shared_credential_fallback",
|
||||
hostId: host.id as number,
|
||||
requestingUserId,
|
||||
error:
|
||||
sharedCredError instanceof Error
|
||||
? sharedCredError.message
|
||||
: "Unknown error",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const userId = host.userId as string;
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
@@ -1747,29 +1520,24 @@ async function resolveHostCredentials(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
return {
|
||||
...host,
|
||||
username: credential.username,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
password: credential.password,
|
||||
key: credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
};
|
||||
|
||||
if (!host.overrideCredentialUsername) {
|
||||
resolvedHost.username = credential.username;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1969,40 +1737,6 @@ router.delete(
|
||||
});
|
||||
}
|
||||
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
if (hostIds.length > 0) {
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(inArray(sshCredentialUsage.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.hostId, hostIds));
|
||||
|
||||
await db.delete(hostAccess).where(inArray(hostAccess.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||
@@ -2105,12 +1839,10 @@ router.post(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!["password", "key", "credential", "none"].includes(hostData.authType)
|
||||
) {
|
||||
if (!["password", "key", "credential"].includes(hostData.authType)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', or 'none'`,
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', or 'credential'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -2165,38 +1897,13 @@ router.post(
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableDocker: hostData.enableDocker || false,
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections
|
||||
? JSON.stringify(hostData.tunnelConnections)
|
||||
: "[]",
|
||||
jumpHosts: hostData.jumpHosts
|
||||
? JSON.stringify(hostData.jumpHosts)
|
||||
: null,
|
||||
quickActions: hostData.quickActions
|
||||
? JSON.stringify(hostData.quickActions)
|
||||
: null,
|
||||
statsConfig: hostData.statsConfig
|
||||
? JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig
|
||||
? JSON.stringify(hostData.terminalConfig)
|
||||
: null,
|
||||
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
||||
? "true"
|
||||
: "false",
|
||||
notes: hostData.notes || null,
|
||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain
|
||||
? JSON.stringify(hostData.socks5ProxyChain)
|
||||
: null,
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -15,11 +15,6 @@ import {
|
||||
sshCredentialUsage,
|
||||
recentActivity,
|
||||
snippets,
|
||||
snippetFolders,
|
||||
sshFolders,
|
||||
commandHistory,
|
||||
roles,
|
||||
userRoles,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -139,54 +134,6 @@ function isNonEmptyString(val: unknown): val is string {
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
try {
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
|
||||
await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
|
||||
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
await db.delete(snippets).where(eq(snippets.userId, userId));
|
||||
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
|
||||
|
||||
await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
|
||||
|
||||
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
|
||||
|
||||
await db.delete(sshData).where(eq(sshData.userId, userId));
|
||||
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${userId}`);
|
||||
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
|
||||
authLogger.success("User and all related data deleted successfully", {
|
||||
operation: "delete_user_and_related_data_complete",
|
||||
userId,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete user and related data", error, {
|
||||
operation: "delete_user_and_related_data_failed",
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Route: Create traditional user (username/password)
|
||||
// POST /users/create
|
||||
router.post("/create", async (req, res) => {
|
||||
@@ -263,34 +210,6 @@ router.post("/create", async (req, res) => {
|
||||
totp_backup_codes: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const defaultRoleName = isFirstUser ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
|
||||
if (defaultRole.length > 0) {
|
||||
await db.insert(userRoles).values({
|
||||
userId: id,
|
||||
roleId: defaultRole[0].id,
|
||||
grantedBy: id,
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Default role not found during user registration", {
|
||||
operation: "assign_default_role",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign default role", roleError, {
|
||||
operation: "assign_default_role",
|
||||
userId: id,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.registerUser(id, password);
|
||||
} catch (encryptionError) {
|
||||
@@ -897,41 +816,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
scopes: String(config.scopes),
|
||||
});
|
||||
|
||||
try {
|
||||
const defaultRoleName = isFirstUser ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
|
||||
if (defaultRole.length > 0) {
|
||||
await db.insert(userRoles).values({
|
||||
userId: id,
|
||||
roleId: defaultRole[0].id,
|
||||
grantedBy: id,
|
||||
});
|
||||
} else {
|
||||
authLogger.warn(
|
||||
"Default role not found during OIDC user registration",
|
||||
{
|
||||
operation: "assign_default_role_oidc",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error(
|
||||
"Failed to assign default role to OIDC user",
|
||||
roleError,
|
||||
{
|
||||
operation: "assign_default_role_oidc",
|
||||
userId: id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
@@ -1171,19 +1055,6 @@ router.post("/login", async (req, res) => {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
|
||||
} catch (error) {
|
||||
authLogger.warn("Failed to re-encrypt pending shared credentials", {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId: userRecord.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
const tempToken = await authManager.generateJWTToken(userRecord.id, {
|
||||
pendingTOTP: true,
|
||||
@@ -1257,7 +1128,15 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
sessionId = payload?.sessionId;
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
authLogger.debug(
|
||||
"Token verification failed during logout (expected if token expired)",
|
||||
{
|
||||
operation: "logout_token_verify_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
@@ -2373,8 +2252,36 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
|
||||
const targetUserId = targetUser[0].id;
|
||||
|
||||
// Use the comprehensive deletion utility
|
||||
await deleteUserAndRelatedData(targetUserId);
|
||||
try {
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, targetUserId));
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, targetUserId));
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, targetUserId));
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, targetUserId));
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, targetUserId));
|
||||
await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, targetUserId));
|
||||
await db.delete(snippets).where(eq(snippets.userId, targetUserId));
|
||||
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, targetUserId));
|
||||
} catch (cleanupError) {
|
||||
authLogger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
||||
throw cleanupError;
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, targetUserId));
|
||||
|
||||
authLogger.success(
|
||||
`User ${username} deleted by admin ${adminUser[0].username}`,
|
||||
@@ -2789,7 +2696,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
await deleteUserAndRelatedData(oidcUserId);
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, oidcUserId));
|
||||
|
||||
await db.delete(users).where(eq(users.id, oidcUserId));
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${oidcUserId}`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
|
||||
108
src/backend/guacamole/guacamole-server.ts
Normal file
108
src/backend/guacamole/guacamole-server.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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 type { IncomingMessage } from "http";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
// Configuration from environment
|
||||
const GUACD_HOST = process.env.GUACD_HOST || "localhost";
|
||||
const GUACD_PORT = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
const GUAC_WS_PORT = 30007;
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
};
|
||||
|
||||
const guacdOptions = {
|
||||
host: GUACD_HOST,
|
||||
port: GUACD_PORT,
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
crypt: {
|
||||
cypher: "AES-256-CBC",
|
||||
key: tokenService.getEncryptionKey(),
|
||||
},
|
||||
log: {
|
||||
level: process.env.NODE_ENV === "production" ? "ERRORS" : "VERBOSE",
|
||||
stdLog: (...args: unknown[]) => {
|
||||
guacLogger.info(args.join(" "), { operation: "guac_log" });
|
||||
},
|
||||
errorLog: (...args: unknown[]) => {
|
||||
guacLogger.error(args.join(" "), { operation: "guac_error" });
|
||||
},
|
||||
},
|
||||
// Allow width, height, and dpi to be passed as query parameters
|
||||
// This allows the client to request the appropriate resolution at connection time
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height", "dpi"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
rdp: {
|
||||
security: "any",
|
||||
"ignore-cert": true,
|
||||
"enable-wallpaper": false,
|
||||
"enable-font-smoothing": true,
|
||||
"enable-desktop-composition": false,
|
||||
"disable-audio": false,
|
||||
"enable-drive": false,
|
||||
"resize-method": "display-update",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
"cursor": "remote",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
telnet: {
|
||||
"terminal-type": "xterm-256color",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Create the guacamole-lite server
|
||||
const guacServer = new GuacamoleLite(
|
||||
websocketOptions,
|
||||
guacdOptions,
|
||||
clientOptions
|
||||
);
|
||||
|
||||
// Add authentication via processConnectionSettings callback
|
||||
guacServer.on("open", (clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacServer.on("close", (clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacServer.on("error", (clientConnection: { connectionSettings?: Record<string, unknown> }, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
});
|
||||
});
|
||||
|
||||
guacLogger.info(`Guacamole WebSocket server started on port ${GUAC_WS_PORT}`, {
|
||||
operation: "guac_server_start",
|
||||
guacdHost: GUACD_HOST,
|
||||
guacdPort: GUACD_PORT,
|
||||
});
|
||||
|
||||
export { guacServer, tokenService };
|
||||
|
||||
159
src/backend/guacamole/routes.ts
Normal file
159
src/backend/guacamole/routes.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import express from "express";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { guacLogger } from "../utils/logger.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// Apply authentication middleware
|
||||
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" });
|
||||
}
|
||||
|
||||
// Log received options for debugging
|
||||
guacLogger.info("Guacamole token request received", {
|
||||
operation: "guac_token_request",
|
||||
type,
|
||||
hostname,
|
||||
port,
|
||||
optionKeys: Object.keys(options),
|
||||
optionsCount: Object.keys(options).length,
|
||||
});
|
||||
|
||||
// Log specific option values for debugging
|
||||
if (Object.keys(options).length > 0) {
|
||||
guacLogger.info("Guacamole options received", {
|
||||
operation: "guac_token_options",
|
||||
options: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
guacLogger.info("Generated guacamole connection token", {
|
||||
operation: "guac_token_generated",
|
||||
userId,
|
||||
type,
|
||||
hostname,
|
||||
});
|
||||
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /guacamole/status
|
||||
* Check if guacd is reachable
|
||||
*/
|
||||
router.get("/status", async (req, res) => {
|
||||
try {
|
||||
const guacdHost = process.env.GUACD_HOST || "localhost";
|
||||
const guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||
|
||||
// Simple TCP check to see if guacd is responding
|
||||
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: 30007,
|
||||
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;
|
||||
|
||||
198
src/backend/guacamole/token-service.ts
Normal file
198
src/backend/guacamole/token-service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
// RDP specific
|
||||
security?: string;
|
||||
"ignore-cert"?: boolean;
|
||||
"enable-wallpaper"?: boolean;
|
||||
"enable-drive"?: boolean;
|
||||
"drive-path"?: string;
|
||||
"create-drive-path"?: boolean;
|
||||
// VNC specific
|
||||
"swap-red-blue"?: boolean;
|
||||
cursor?: string;
|
||||
// Telnet specific
|
||||
"terminal-type"?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
const KEY_LENGTH = 32; // 256 bits = 32 bytes
|
||||
|
||||
export class GuacamoleTokenService {
|
||||
private static instance: GuacamoleTokenService;
|
||||
private encryptionKey: Buffer;
|
||||
|
||||
private constructor() {
|
||||
// Use existing JWT secret or generate a dedicated key
|
||||
this.encryptionKey = this.initializeKey();
|
||||
}
|
||||
|
||||
static getInstance(): GuacamoleTokenService {
|
||||
if (!GuacamoleTokenService.instance) {
|
||||
GuacamoleTokenService.instance = new GuacamoleTokenService();
|
||||
}
|
||||
return GuacamoleTokenService.instance;
|
||||
}
|
||||
|
||||
private initializeKey(): Buffer {
|
||||
// Check for dedicated guacamole key first (must be 32 bytes / 64 hex chars)
|
||||
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
|
||||
if (existingKey) {
|
||||
// If it's hex encoded (64 chars = 32 bytes)
|
||||
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||
return Buffer.from(existingKey, "hex");
|
||||
}
|
||||
// If it's already 32 bytes
|
||||
if (existingKey.length === KEY_LENGTH) {
|
||||
return Buffer.from(existingKey, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a deterministic key from JWT_SECRET if available
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
// SHA-256 produces exactly 32 bytes - perfect for AES-256
|
||||
return crypto.createHash("sha256").update(jwtSecret + "_guacamole").digest();
|
||||
}
|
||||
|
||||
// Last resort: generate random key (note: won't persist across restarts)
|
||||
guacLogger.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
getEncryptionKey(): Buffer {
|
||||
return this.encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt connection settings into a token for guacamole-lite
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a token (for verification/debugging purposes)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for RDP
|
||||
* security options: "any", "nla", "nla-ext", "tls", "rdp", "vmconnect"
|
||||
*/
|
||||
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", // NLA is required for modern Windows (10/11, Server 2016+)
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for VNC
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection token for Telnet
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
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 { sshData, 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 dockerConsoleLogger = systemLogger;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: any;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
host: "0.0.0.0",
|
||||
port: 30008,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!decoded || !decoded.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
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", () => {
|
||||
// Ignore stderr
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: any[],
|
||||
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(sshData)
|
||||
.where(and(eq(sshData.id, jumpHostId), eq(sshData.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) {
|
||||
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: jumpHost.id,
|
||||
});
|
||||
jumpHost.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedCredentials: any = {
|
||||
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,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: any = {
|
||||
host: 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 userId = (req as any).userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const { hostConfig, containerId, shell, cols, rows } =
|
||||
message.data as {
|
||||
hostConfig: SSHHost;
|
||||
containerId: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof hostConfig.jumpHosts === "string" &&
|
||||
hostConfig.jumpHosts
|
||||
) {
|
||||
try {
|
||||
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
|
||||
} catch (e) {
|
||||
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
hostConfig.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostConfig || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let resolvedCredentials: any = {
|
||||
password: hostConfig.password,
|
||||
sshKey: hostConfig.key,
|
||||
keyPassword: hostConfig.keyPassword,
|
||||
authType: hostConfig.authType,
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key,
|
||||
keyPassword:
|
||||
credential.key_password || credential.keyPassword,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: any = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
hostConfig.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
const stream = await new Promise<any>((resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostConfig.ip,
|
||||
hostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
});
|
||||
config.sock = stream;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
};
|
||||
|
||||
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", () => {
|
||||
// Ignore stderr
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
dockerConsoleLogger.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}`;
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
dockerConsoleLogger.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;
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: data.toString("utf8"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {});
|
||||
|
||||
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) {
|
||||
dockerConsoleLogger.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);
|
||||
}
|
||||
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:
|
||||
dockerConsoleLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dockerConsoleLogger.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", () => {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
dockerConsoleLogger.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, sessionId) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
wss.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import { fileLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
const hasExecutePermission =
|
||||
@@ -279,7 +278,6 @@ interface PendingTOTPSession {
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
@@ -343,27 +341,6 @@ function getMimeType(fileName: string): string {
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
function detectBinary(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
|
||||
const sampleSize = Math.min(buffer.length, 8192);
|
||||
let nullBytes = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
if (++nullBytes > 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return nullBytes / sampleSize > 0.01;
|
||||
}
|
||||
|
||||
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
const {
|
||||
sessionId,
|
||||
@@ -379,12 +356,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
userProvidedPassword,
|
||||
forceKeyboardInteractive,
|
||||
jumpHosts,
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
} = req.body;
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -411,15 +382,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
if (sshSessions[sessionId]?.isConnected) {
|
||||
cleanupSession(sessionId);
|
||||
}
|
||||
|
||||
// Clean up any stale pending TOTP sessions
|
||||
if (pendingTOTPSessions[sessionId]) {
|
||||
try {
|
||||
pendingTOTPSessions[sessionId].client.end();
|
||||
} catch {}
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||
@@ -583,7 +545,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
.json({ error: "Password required for password authentication" });
|
||||
}
|
||||
|
||||
config.password = resolvedCredentials.password;
|
||||
if (!forceKeyboardInteractive) {
|
||||
config.password = resolvedCredentials.password;
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
} else {
|
||||
fileLogger.warn(
|
||||
@@ -749,7 +713,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
resolvedPassword: resolvedCredentials.password,
|
||||
totpAttempts: 0,
|
||||
};
|
||||
|
||||
res.json({
|
||||
@@ -822,7 +785,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
prompts,
|
||||
totpPromptIndex: passwordPromptIndex,
|
||||
resolvedPassword: resolvedCredentials.password,
|
||||
totpAttempts: 0,
|
||||
};
|
||||
|
||||
res.json({
|
||||
@@ -846,47 +808,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
useSocks5 &&
|
||||
(socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(ip, port, {
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain: socks5ProxyChain as any,
|
||||
});
|
||||
|
||||
if (socks5Socket) {
|
||||
config.sock = socks5Socket;
|
||||
client.connect(config);
|
||||
return;
|
||||
} else {
|
||||
fileLogger.error("SOCKS5 socket is null for SFTP", undefined, {
|
||||
operation: "sftp_socks5_socket_null",
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
fileLogger.error("SOCKS5 connection failed", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
sessionId,
|
||||
hostId,
|
||||
proxyHost: socks5Host,
|
||||
proxyPort: socks5Port || 1080,
|
||||
});
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
});
|
||||
}
|
||||
} else if (jumpHosts && jumpHosts.length > 0 && userId) {
|
||||
if (jumpHosts && jumpHosts.length > 0 && userId) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||
|
||||
@@ -969,7 +891,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
try {
|
||||
session.client.end();
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
sshLogger.debug("Operation failed, continuing", { error });
|
||||
}
|
||||
fileLogger.warn("TOTP session timeout before code submission", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
@@ -1389,11 +1313,11 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let binaryData = Buffer.alloc(0);
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
binaryData = Buffer.concat([binaryData, chunk]);
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
@@ -1417,23 +1341,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const isBinary = detectBinary(binaryData);
|
||||
|
||||
if (isBinary) {
|
||||
const base64Content = binaryData.toString("base64");
|
||||
res.json({
|
||||
content: base64Content,
|
||||
path: filePath,
|
||||
encoding: "base64",
|
||||
});
|
||||
} else {
|
||||
const textContent = binaryData.toString("utf8");
|
||||
res.json({
|
||||
content: textContent,
|
||||
path: filePath,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
res.json({ content: data, path: filePath });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1477,16 +1385,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
const testBuffer = Buffer.from(content, "base64");
|
||||
if (testBuffer.toString("base64") === content) {
|
||||
fileBuffer = testBuffer;
|
||||
} else {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} catch {
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
fileBuffer = content;
|
||||
} else {
|
||||
@@ -1562,22 +1461,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
contentBuffer = Buffer.from(content, "base64");
|
||||
if (contentBuffer.toString("base64") !== content) {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} catch {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
contentBuffer = content;
|
||||
} else {
|
||||
contentBuffer = Buffer.from(content);
|
||||
}
|
||||
const base64Content = contentBuffer.toString("base64");
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
@@ -1695,7 +1579,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
let fileBuffer;
|
||||
try {
|
||||
if (typeof content === "string") {
|
||||
fileBuffer = Buffer.from(content, "base64");
|
||||
fileBuffer = Buffer.from(content, "utf8");
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
fileBuffer = content;
|
||||
} else {
|
||||
@@ -1778,22 +1662,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
contentBuffer = Buffer.from(content, "base64");
|
||||
if (contentBuffer.toString("base64") !== content) {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} catch {
|
||||
contentBuffer = Buffer.from(content, "utf8");
|
||||
}
|
||||
} else if (Buffer.isBuffer(content)) {
|
||||
contentBuffer = content;
|
||||
} else {
|
||||
contentBuffer = Buffer.from(content);
|
||||
}
|
||||
const base64Content = contentBuffer.toString("base64");
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
const chunkSize = 1000000;
|
||||
const chunks = [];
|
||||
|
||||
@@ -3071,10 +2940,21 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
fileLogger.debug("Extract stdout", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
output: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
fileLogger.debug("Extract stderr", {
|
||||
operation: "extract_archive",
|
||||
sessionId,
|
||||
error: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
@@ -3252,10 +3132,21 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
fileLogger.debug("Compress stdout", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
output: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
fileLogger.debug("Compress stderr", {
|
||||
operation: "compress_files",
|
||||
sessionId,
|
||||
error: data.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -33,12 +32,6 @@ interface ConnectToHostData {
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
@@ -137,12 +130,10 @@ async function createJumpHostChain(
|
||||
const clients: Client[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs = await Promise.all(
|
||||
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
|
||||
);
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (!jumpHostConfigs[i]) {
|
||||
if (!jumpHostConfig) {
|
||||
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
@@ -150,10 +141,6 @@ async function createJumpHostChain(
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
const jumpHostConfig = jumpHostConfigs[i];
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
@@ -331,8 +318,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let totpAttempts = 0;
|
||||
let totpTimeout: NodeJS.Timeout | null = null;
|
||||
let isKeyboardInteractive = false;
|
||||
let keyboardInteractiveResponded = false;
|
||||
let isConnecting = false;
|
||||
@@ -449,15 +434,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
if (totpTimeout) {
|
||||
clearTimeout(totpTimeout);
|
||||
totpTimeout = null;
|
||||
}
|
||||
const totpCode = totpData.code;
|
||||
totpAttempts++;
|
||||
keyboardInteractiveFinish([totpCode]);
|
||||
keyboardInteractiveFinish = null;
|
||||
totpPromptSent = false;
|
||||
} else {
|
||||
sshLogger.warn("TOTP response received but no callback available", {
|
||||
operation: "totp_response_error",
|
||||
@@ -478,10 +457,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
case "password_response": {
|
||||
const passwordData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||
if (totpTimeout) {
|
||||
clearTimeout(totpTimeout);
|
||||
totpTimeout = null;
|
||||
}
|
||||
const password = passwordData.code;
|
||||
keyboardInteractiveFinish([password]);
|
||||
keyboardInteractiveFinish = null;
|
||||
@@ -621,13 +596,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
isConnecting,
|
||||
isConnected,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Connection already in progress",
|
||||
code: "DUPLICATE_CONNECTION",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -648,7 +616,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 30000);
|
||||
}, 120000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
let authMethodNotAvailable = false;
|
||||
@@ -1016,25 +984,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
totpTimeout = setTimeout(() => {
|
||||
if (keyboardInteractiveFinish) {
|
||||
keyboardInteractiveFinish = null;
|
||||
totpPromptSent = false;
|
||||
sshLogger.warn("TOTP prompt timeout", {
|
||||
operation: "totp_timeout",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
@@ -1069,24 +1018,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
totpTimeout = setTimeout(() => {
|
||||
if (keyboardInteractiveFinish) {
|
||||
keyboardInteractiveFinish = null;
|
||||
keyboardInteractiveResponded = false;
|
||||
sshLogger.warn("Password prompt timeout", {
|
||||
operation: "password_timeout",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password verification timeout. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
@@ -1115,10 +1046,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 30000,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
timeout: 30000,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
@@ -1194,7 +1125,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
if (!hostConfig.forceKeyboardInteractive) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.key
|
||||
@@ -1247,49 +1180,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.useSocks5 &&
|
||||
(hostConfig.socks5Host ||
|
||||
(hostConfig.socks5ProxyChain &&
|
||||
(hostConfig.socks5ProxyChain as any).length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(ip, port, {
|
||||
useSocks5: hostConfig.useSocks5,
|
||||
socks5Host: hostConfig.socks5Host,
|
||||
socks5Port: hostConfig.socks5Port,
|
||||
socks5Username: hostConfig.socks5Username,
|
||||
socks5Password: hostConfig.socks5Password,
|
||||
socks5ProxyChain: hostConfig.socks5ProxyChain as any,
|
||||
});
|
||||
|
||||
if (socks5Socket) {
|
||||
connectConfig.sock = socks5Socket;
|
||||
sshConn.connect(connectConfig);
|
||||
return;
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
sshLogger.error("SOCKS5 connection failed", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
hostId: id,
|
||||
proxyHost: hostConfig.socks5Host,
|
||||
proxyPort: hostConfig.socks5Port || 1080,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.jumpHosts &&
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
@@ -1386,11 +1276,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (totpTimeout) {
|
||||
clearTimeout(totpTimeout);
|
||||
totpTimeout = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
@@ -1416,13 +1301,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
totpPromptSent = false;
|
||||
totpAttempts = 0;
|
||||
isKeyboardInteractive = false;
|
||||
keyboardInteractiveResponded = false;
|
||||
keyboardInteractiveFinish = null;
|
||||
isConnecting = false;
|
||||
isConnected = false;
|
||||
isCleaningUp = false;
|
||||
|
||||
setTimeout(() => {
|
||||
isCleaningUp = false;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import express, { type Response } from "express";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client } from "ssh2";
|
||||
@@ -13,16 +13,12 @@ import type {
|
||||
TunnelStatus,
|
||||
VerificationData,
|
||||
ErrorType,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { CONNECTION_STATES } from "../../types/index.js";
|
||||
import { tunnelLogger, sshLogger } from "../utils/logger.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
@@ -67,10 +63,6 @@ app.use(
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const activeTunnels = new Map<string, Client>();
|
||||
const retryCounters = new Map<string, number>();
|
||||
const connectionStatus = new Map<string, TunnelStatus>();
|
||||
@@ -85,7 +77,6 @@ const tunnelConnecting = new Set<string>();
|
||||
|
||||
const tunnelConfigs = new Map<string, TunnelConfig>();
|
||||
const activeTunnelProcesses = new Map<string, ChildProcess>();
|
||||
const pendingTunnelOperations = new Map<string, Promise<void>>();
|
||||
|
||||
function broadcastTunnelStatus(tunnelName: string, status: TunnelStatus): void {
|
||||
if (
|
||||
@@ -163,75 +154,10 @@ function getTunnelMarker(tunnelName: string) {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
function normalizeTunnelName(
|
||||
hostId: number,
|
||||
tunnelIndex: number,
|
||||
displayName: string,
|
||||
sourcePort: number,
|
||||
endpointHost: string,
|
||||
endpointPort: number,
|
||||
): string {
|
||||
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
|
||||
}
|
||||
|
||||
function parseTunnelName(tunnelName: string): {
|
||||
hostId?: number;
|
||||
tunnelIndex?: number;
|
||||
displayName: string;
|
||||
sourcePort: string;
|
||||
endpointHost: string;
|
||||
endpointPort: string;
|
||||
isLegacyFormat: boolean;
|
||||
} {
|
||||
const parts = tunnelName.split("::");
|
||||
|
||||
if (parts.length === 6) {
|
||||
return {
|
||||
hostId: parseInt(parts[0]),
|
||||
tunnelIndex: parseInt(parts[1]),
|
||||
displayName: parts[2],
|
||||
sourcePort: parts[3],
|
||||
endpointHost: parts[4],
|
||||
endpointPort: parts[5],
|
||||
isLegacyFormat: false,
|
||||
};
|
||||
}
|
||||
|
||||
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
|
||||
|
||||
const legacyParts = tunnelName.split("_");
|
||||
return {
|
||||
displayName: legacyParts[0] || "unknown",
|
||||
sourcePort: legacyParts[legacyParts.length - 3] || "0",
|
||||
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
|
||||
endpointPort: legacyParts[legacyParts.length - 1] || "0",
|
||||
isLegacyFormat: true,
|
||||
};
|
||||
}
|
||||
|
||||
function validateTunnelConfig(
|
||||
tunnelName: string,
|
||||
tunnelConfig: TunnelConfig,
|
||||
): boolean {
|
||||
const parsed = parseTunnelName(tunnelName);
|
||||
|
||||
if (parsed.isLegacyFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.hostId === tunnelConfig.sourceHostId &&
|
||||
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
|
||||
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
|
||||
parsed.endpointHost === tunnelConfig.endpointHost &&
|
||||
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupTunnelResources(
|
||||
function cleanupTunnelResources(
|
||||
tunnelName: string,
|
||||
forceCleanup = false,
|
||||
): Promise<void> {
|
||||
): void {
|
||||
if (cleanupInProgress.has(tunnelName)) {
|
||||
return;
|
||||
}
|
||||
@@ -244,16 +170,13 @@ async function cleanupTunnelResources(
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName);
|
||||
if (tunnelConfig) {
|
||||
await new Promise<void>((resolve) => {
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
if (err) {
|
||||
tunnelLogger.error(
|
||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
killRemoteTunnelByMarker(tunnelConfig, tunnelName, (err) => {
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
if (err) {
|
||||
tunnelLogger.error(
|
||||
`Failed to kill remote tunnel for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cleanupInProgress.delete(tunnelName);
|
||||
@@ -349,11 +272,11 @@ function resetRetryState(tunnelName: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDisconnect(
|
||||
function handleDisconnect(
|
||||
tunnelName: string,
|
||||
tunnelConfig: TunnelConfig | null,
|
||||
shouldRetry = true,
|
||||
): Promise<void> {
|
||||
): void {
|
||||
if (tunnelVerifications.has(tunnelName)) {
|
||||
try {
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
@@ -363,11 +286,7 @@ async function handleDisconnect(
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
while (cleanupInProgress.has(tunnelName)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName);
|
||||
cleanupTunnelResources(tunnelName);
|
||||
|
||||
if (manualDisconnects.has(tunnelName)) {
|
||||
resetRetryState(tunnelName);
|
||||
@@ -571,76 +490,43 @@ async function connectSSHTunnel(
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
};
|
||||
|
||||
const effectiveUserId =
|
||||
tunnelConfig.requestingUserId || tunnelConfig.sourceUserId;
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && effectiveUserId) {
|
||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
||||
try {
|
||||
if (
|
||||
tunnelConfig.requestingUserId &&
|
||||
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
|
||||
) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId);
|
||||
if (userDataKey) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
|
||||
if (tunnelConfig.sourceHostId) {
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
tunnelConfig.sourceHostId,
|
||||
tunnelConfig.requestingUserId,
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
resolvedSourceCredentials = {
|
||||
password: sharedCred.password,
|
||||
sshKey: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authMethod: sharedCred.authType,
|
||||
};
|
||||
} else {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`;
|
||||
tunnelLogger.error(errorMessage);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userDataKey = DataCrypto.getUserDataKey(effectiveUserId);
|
||||
if (userDataKey) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
"ssh_credentials",
|
||||
effectiveUserId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password ||
|
||||
credential.keyPassword) as string | undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authMethod: (credential.auth_type ||
|
||||
credential.authType) as string,
|
||||
};
|
||||
}
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
||||
| string
|
||||
| undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authMethod: (credential.auth_type || credential.authType) as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source credentials", {
|
||||
tunnelLogger.warn("Failed to resolve source credentials from database", {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
credentialId: tunnelConfig.sourceCredentialId,
|
||||
@@ -695,7 +581,12 @@ async function connectSSHTunnel(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.endpointCredentialId)),
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.endpointCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.endpointUserId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.endpointUserId,
|
||||
);
|
||||
@@ -1125,51 +1016,6 @@ async function connectSSHTunnel(
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
conn.connect(connOptions);
|
||||
return;
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
tunnelLogger.error("SOCKS5 connection failed for tunnel", socks5Error, {
|
||||
operation: "socks5_connect",
|
||||
tunnelName,
|
||||
proxyHost: tunnelConfig.socks5Host,
|
||||
proxyPort: tunnelConfig.socks5Port || 1080,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason:
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
@@ -1196,7 +1042,12 @@ async function killRemoteTunnelByMarker(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, tunnelConfig.sourceCredentialId),
|
||||
eq(sshCredentials.userId, tunnelConfig.sourceUserId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
@@ -1397,57 +1248,7 @@ async function killRemoteTunnelByMarker(
|
||||
callback(err);
|
||||
});
|
||||
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
(async () => {
|
||||
try {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
conn.connect(connOptions);
|
||||
} else {
|
||||
callback(new Error("Failed to create SOCKS5 connection"));
|
||||
}
|
||||
} catch (socks5Error) {
|
||||
tunnelLogger.error(
|
||||
"SOCKS5 connection failed for killing tunnel",
|
||||
socks5Error,
|
||||
{
|
||||
operation: "socks5_connect_kill",
|
||||
tunnelName,
|
||||
proxyHost: tunnelConfig.socks5Host,
|
||||
proxyPort: tunnelConfig.socks5Port || 1080,
|
||||
},
|
||||
);
|
||||
callback(
|
||||
new Error(
|
||||
"SOCKS5 proxy connection failed: " +
|
||||
(socks5Error instanceof Error
|
||||
? socks5Error.message
|
||||
: "Unknown error"),
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
app.get("/ssh/tunnel/status", (req, res) => {
|
||||
@@ -1465,291 +1266,103 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => {
|
||||
res.json({ name: tunnelName, status });
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/ssh/tunnel/connect",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const tunnelConfig: TunnelConfig = req.body;
|
||||
const userId = req.userId;
|
||||
app.post("/ssh/tunnel/connect", (req, res) => {
|
||||
const tunnelConfig: TunnelConfig = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
if (!tunnelConfig || !tunnelConfig.name) {
|
||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||
}
|
||||
|
||||
if (!tunnelConfig || !tunnelConfig.name) {
|
||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||
}
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
cleanupTunnelResources(tunnelName);
|
||||
|
||||
try {
|
||||
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||
tunnelLogger.error(`Tunnel config validation failed`, {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
configHostId: tunnelConfig.sourceHostId,
|
||||
configTunnelIndex: tunnelConfig.tunnelIndex,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Tunnel configuration does not match tunnel name",
|
||||
});
|
||||
}
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
if (tunnelConfig.sourceHostId) {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
tunnelConfig.sourceHostId,
|
||||
"read",
|
||||
);
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
tunnelLogger.warn("User attempted tunnel connect without access", {
|
||||
operation: "tunnel_connect_unauthorized",
|
||||
userId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
tunnelName,
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
connectSSHTunnel(tunnelConfig, 0).catch((error) => {
|
||||
tunnelLogger.error(
|
||||
`Failed to connect tunnel ${tunnelConfig.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (accessInfo.isShared && !accessInfo.isOwner) {
|
||||
tunnelConfig.requestingUserId = userId;
|
||||
}
|
||||
}
|
||||
res.json({ message: "Connection request received", tunnelName });
|
||||
});
|
||||
|
||||
if (pendingTunnelOperations.has(tunnelName)) {
|
||||
try {
|
||||
await pendingTunnelOperations.get(tunnelName);
|
||||
} catch (error) {
|
||||
tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName });
|
||||
}
|
||||
}
|
||||
app.post("/ssh/tunnel/disconnect", (req, res) => {
|
||||
const { tunnelName } = req.body;
|
||||
|
||||
const operation = (async () => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName);
|
||||
manualDisconnects.add(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
if (tunnelConfigs.has(tunnelName)) {
|
||||
const existingConfig = tunnelConfigs.get(tunnelName);
|
||||
if (
|
||||
existingConfig &&
|
||||
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
|
||||
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
|
||||
) {
|
||||
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
|
||||
}
|
||||
}
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername) {
|
||||
try {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
const allHostsResponse = await axios.get(
|
||||
"http://localhost:30001/ssh/db/host/internal/all",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Auth-Token": internalAuthToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnelConfig.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnelConfig.endpointHost,
|
||||
);
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
if (!endpointHost) {
|
||||
throw new Error(
|
||||
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
|
||||
);
|
||||
}
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
tunnelConfig.endpointIP = endpointHost.ip;
|
||||
tunnelConfig.endpointSSHPort = endpointHost.port;
|
||||
tunnelConfig.endpointUsername = endpointHost.username;
|
||||
tunnelConfig.endpointPassword = endpointHost.password;
|
||||
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
||||
tunnelConfig.endpointSSHKey = endpointHost.key;
|
||||
tunnelConfig.endpointKeyPassword = endpointHost.keyPassword;
|
||||
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
||||
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
|
||||
tunnelConfig.endpointUserId = endpointHost.userId;
|
||||
} catch (resolveError) {
|
||||
tunnelLogger.error(
|
||||
"Failed to resolve endpoint host",
|
||||
resolveError,
|
||||
{
|
||||
operation: "tunnel_connect_resolve_endpoint_failed",
|
||||
tunnelName,
|
||||
endpointHost: tunnelConfig.endpointHost,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to resolve endpoint host: ${resolveError instanceof Error ? resolveError.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
res.json({ message: "Disconnect request received", tunnelName });
|
||||
});
|
||||
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
await connectSSHTunnel(tunnelConfig, 0);
|
||||
})();
|
||||
app.post("/ssh/tunnel/cancel", (req, res) => {
|
||||
const { tunnelName } = req.body;
|
||||
|
||||
pendingTunnelOperations.set(tunnelName, operation);
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
res.json({ message: "Connection request received", tunnelName });
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
operation.finally(() => {
|
||||
pendingTunnelOperations.delete(tunnelName);
|
||||
});
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to process tunnel connect", error, {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to connect tunnel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
app.post(
|
||||
"/ssh/tunnel/disconnect",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { tunnelName } = req.body;
|
||||
const userId = req.userId;
|
||||
if (countdownIntervals.has(tunnelName)) {
|
||||
clearInterval(countdownIntervals.get(tunnelName)!);
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const config = tunnelConfigs.get(tunnelName);
|
||||
if (config && config.sourceHostId) {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
config.sourceHostId,
|
||||
"read",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
}
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
manualDisconnects.add(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
res.json({ message: "Disconnect request received", tunnelName });
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to disconnect tunnel", error, {
|
||||
operation: "tunnel_disconnect",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to disconnect tunnel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/ssh/tunnel/cancel",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { tunnelName } = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = tunnelConfigs.get(tunnelName);
|
||||
if (config && config.sourceHostId) {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
config.sourceHostId,
|
||||
"read",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (countdownIntervals.has(tunnelName)) {
|
||||
clearInterval(countdownIntervals.get(tunnelName)!);
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
res.json({ message: "Cancel request received", tunnelName });
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to cancel tunnel retry", error, {
|
||||
operation: "tunnel_cancel",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to cancel tunnel retry" });
|
||||
}
|
||||
},
|
||||
);
|
||||
res.json({ message: "Cancel request received", tunnelName });
|
||||
});
|
||||
|
||||
async function initializeAutoStartTunnels(): Promise<void> {
|
||||
try {
|
||||
@@ -1795,19 +1408,12 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
const tunnelIndex =
|
||||
host.tunnelConnections.indexOf(tunnelConnection);
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: normalizeTunnelName(
|
||||
host.id,
|
||||
tunnelIndex,
|
||||
host.name || `${host.username}@${host.ip}`,
|
||||
tunnelConnection.sourcePort,
|
||||
tunnelConnection.endpointHost,
|
||||
tunnelConnection.endpointPort,
|
||||
),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: tunnelIndex,
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${
|
||||
tunnelConnection.sourcePort
|
||||
}_${tunnelConnection.endpointHost}_${
|
||||
tunnelConnection.endpointPort
|
||||
}`,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
@@ -1823,7 +1429,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointHost: tunnelConnection.endpointHost,
|
||||
endpointPassword:
|
||||
tunnelConnection.endpointPassword ||
|
||||
endpointHost.autostartPassword ||
|
||||
@@ -1848,11 +1453,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
retryInterval: tunnelConnection.retryInterval * 1000,
|
||||
autoStart: tunnelConnection.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
};
|
||||
|
||||
autoStartTunnels.push(tunnelConfig);
|
||||
|
||||
@@ -3,87 +3,28 @@ import type { Client } from "ssh2";
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let stream: any = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (stream) {
|
||||
try {
|
||||
stream.removeAllListeners();
|
||||
if (stream.stderr) {
|
||||
stream.stderr.removeAllListeners();
|
||||
}
|
||||
stream.destroy();
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.exec(command, { pty: false }, (err, _stream) => {
|
||||
if (err) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
client.exec(command, { pty: false }, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
}
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(streamErr);
|
||||
}
|
||||
.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
});
|
||||
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(stderrErr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,20 +26,12 @@ export async function collectCpuMetrics(client: Client): Promise<{
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const [stat1, loadAvgOut, coresOut] = await Promise.race([
|
||||
Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("CPU metrics collection timeout")),
|
||||
25000,
|
||||
),
|
||||
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface LoginRecord {
|
||||
user: string;
|
||||
@@ -47,20 +46,10 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||
|
||||
if (user && user !== "wtmp" && tty !== "system") {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
: date.toISOString();
|
||||
} catch (e) {
|
||||
parsedTime = new Date().toISOString();
|
||||
}
|
||||
|
||||
recentLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
time: new Date(timeStr).toISOString(),
|
||||
status: "success",
|
||||
});
|
||||
if (ip !== "local") {
|
||||
@@ -70,7 +59,9 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
try {
|
||||
const failedOut = await execCommand(
|
||||
@@ -105,20 +96,12 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = timeStr ? new Date(timeStr) : new Date();
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
: date.toISOString();
|
||||
} catch (e) {
|
||||
parsedTime = new Date().toISOString();
|
||||
}
|
||||
|
||||
failedLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
time: timeStr
|
||||
? new Date(timeStr).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: "failed",
|
||||
});
|
||||
if (ip !== "unknown") {
|
||||
@@ -126,7 +109,9 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return {
|
||||
recentLogins: recentLogins.slice(0, 10),
|
||||
|
||||
@@ -68,7 +68,12 @@ export async function collectNetworkMetrics(client: Client): Promise<{
|
||||
txBytes: null,
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect network interface stats", {
|
||||
operation: "network_stats_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
@@ -33,13 +33,11 @@ export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||
const parts = psLines[i].split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
const cpuVal = Number(parts[2]);
|
||||
const memVal = Number(parts[3]);
|
||||
topProcesses.push({
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0",
|
||||
mem: Number.isFinite(memVal) ? memVal.toString() : "0",
|
||||
cpu: parts[2],
|
||||
mem: parts[3],
|
||||
command: parts.slice(10).join(" ").substring(0, 50),
|
||||
});
|
||||
}
|
||||
@@ -48,13 +46,14 @@ export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
|
||||
|
||||
const totalCount = Number(procCount.stdout.trim()) - 1;
|
||||
totalProcesses = Number.isFinite(totalCount) ? totalCount : null;
|
||||
|
||||
const runningCount2 = Number(runningCount.stdout.trim());
|
||||
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
|
||||
} catch (e) {}
|
||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||
runningProcesses = Number(runningCount.stdout.trim());
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect process stats", {
|
||||
operation: "process_stats_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total: totalProcesses,
|
||||
|
||||
@@ -23,7 +23,10 @@ export async function collectSystemMetrics(client: Client): Promise<{
|
||||
kernel = kernelOut.stdout.trim() || null;
|
||||
os = osOut.stdout.trim() || null;
|
||||
} catch (e) {
|
||||
// No error log
|
||||
statsLogger.debug("Failed to collect system info", {
|
||||
operation: "system_info_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,12 @@ export async function collectUptimeMetrics(client: Client): Promise<{
|
||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect uptime", {
|
||||
operation: "uptime_failed",
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
|
||||
@@ -102,10 +102,21 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./ssh/docker.js");
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||
if (process.env.ENABLE_GUACAMOLE !== "false") {
|
||||
try {
|
||||
await import("./guacamole/guacamole-server.js");
|
||||
systemLogger.info("Guacamole server initialized", { operation: "guac_init" });
|
||||
} catch (error) {
|
||||
systemLogger.warn("Failed to initialize Guacamole server (guacd may not be available)", {
|
||||
operation: "guac_init_skip",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
"Received SIGINT signal, initiating graceful shutdown...",
|
||||
|
||||
@@ -154,8 +154,9 @@ class AuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getSqlite, saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { getSqlite, saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
|
||||
const sqlite = getSqlite();
|
||||
|
||||
@@ -168,23 +169,6 @@ class AuthManager {
|
||||
if (migrationResult.migrated) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
}
|
||||
|
||||
try {
|
||||
const { CredentialSystemEncryptionMigration } =
|
||||
await import("./credential-system-encryption-migration.js");
|
||||
const credMigration = new CredentialSystemEncryptionMigration();
|
||||
const credResult = await credMigration.migrateUserCredentials(userId);
|
||||
|
||||
if (credResult.migrated > 0) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Credential migration failed during login", {
|
||||
operation: "login_credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
@@ -247,8 +231,9 @@ class AuthManager {
|
||||
});
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -349,8 +334,9 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -401,8 +387,9 @@ class AuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -443,8 +430,9 @@ class AuthManager {
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
@@ -580,8 +568,9 @@ class AuthManager {
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.then(async () => {
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const remainingSessions = await db
|
||||
@@ -725,8 +714,9 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and, or, isNull } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class CredentialSystemEncryptionMigration {
|
||||
async migrateUserCredentials(userId: string): Promise<{
|
||||
migrated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
throw new Error("User must be logged in to migrate credentials");
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const CSKEK = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
or(
|
||||
isNull(sshCredentials.systemPassword),
|
||||
isNull(sshCredentials.systemKey),
|
||||
isNull(sshCredentials.systemKeyPassword),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const skipped = 0;
|
||||
|
||||
for (const cred of credentials) {
|
||||
try {
|
||||
const plainPassword = cred.password
|
||||
? FieldCrypto.decryptField(
|
||||
cred.password,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKey = cred.key
|
||||
? FieldCrypto.decryptField(
|
||||
cred.key,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKeyPassword = cred.key_password
|
||||
? FieldCrypto.decryptField(
|
||||
cred.key_password,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"key_password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemPassword = plainPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKey = plainKey
|
||||
? FieldCrypto.encryptField(
|
||||
plainKey,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKeyPassword = plainKeyPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainKeyPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key_password",
|
||||
)
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
systemPassword,
|
||||
systemKey,
|
||||
systemKeyPassword,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(sshCredentials.id, cred.id));
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate credential", error, {
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
});
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { migrated, failed, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Credential system encryption migration failed",
|
||||
error,
|
||||
{
|
||||
operation: "credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,52 +475,6 @@ class DataCrypto {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive credential fields with system key for offline sharing
|
||||
* Returns an object with systemPassword, systemKey, systemKeyPassword fields
|
||||
*/
|
||||
static async encryptRecordWithSystemKey<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
systemKey: Buffer,
|
||||
): Promise<Partial<T>> {
|
||||
const systemEncrypted: Record<string, unknown> = {};
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
if (tableName !== "ssh_credentials") {
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
|
||||
if (record.password && typeof record.password === "string") {
|
||||
systemEncrypted.systemPassword = FieldCrypto.encryptField(
|
||||
record.password as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"password",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.key && typeof record.key === "string") {
|
||||
systemEncrypted.systemKey = FieldCrypto.encryptField(
|
||||
record.key as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.key_password && typeof record.key_password === "string") {
|
||||
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
|
||||
record.key_password as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key_password",
|
||||
);
|
||||
}
|
||||
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
|
||||
@@ -327,7 +327,11 @@ class DatabaseFileEncryption {
|
||||
fs.accessSync(envPath, fs.constants.R_OK);
|
||||
envFileReadable = true;
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
databaseLogger.debug("Operation failed, continuing", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.error(
|
||||
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",
|
||||
|
||||
@@ -36,7 +36,7 @@ const SENSITIVE_FIELDS = [
|
||||
|
||||
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
|
||||
|
||||
export class Logger {
|
||||
class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { db } from "../database/db/index.js";
|
||||
import {
|
||||
hostAccess,
|
||||
roles,
|
||||
userRoles,
|
||||
sshData,
|
||||
users,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
|
||||
interface HostAccessInfo {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
permissionLevel?: "view";
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
interface PermissionCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
class PermissionManager {
|
||||
private static instance: PermissionManager;
|
||||
private permissionCache: Map<
|
||||
string,
|
||||
{ permissions: string[]; timestamp: number }
|
||||
>;
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
private constructor() {
|
||||
this.permissionCache = new Map();
|
||||
|
||||
setInterval(() => {
|
||||
this.cleanupExpiredAccess().catch((error) => {
|
||||
databaseLogger.error(
|
||||
"Failed to run periodic host access cleanup",
|
||||
error,
|
||||
{
|
||||
operation: "host_access_cleanup_periodic",
|
||||
},
|
||||
);
|
||||
});
|
||||
}, 60 * 1000);
|
||||
|
||||
setInterval(() => {
|
||||
this.clearPermissionCache();
|
||||
}, this.CACHE_TTL);
|
||||
}
|
||||
|
||||
static getInstance(): PermissionManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new PermissionManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired host access entries
|
||||
*/
|
||||
private async cleanupExpiredAccess(): Promise<void> {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.delete(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
||||
sql`${hostAccess.expiresAt} <= ${now}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: hostAccess.id });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to cleanup expired host access", error, {
|
||||
operation: "host_access_cleanup_failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache
|
||||
*/
|
||||
private clearPermissionCache(): void {
|
||||
this.permissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate permission cache for a specific user
|
||||
*/
|
||||
invalidateUserPermissionCache(userId: string): void {
|
||||
this.permissionCache.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user permissions from roles
|
||||
*/
|
||||
async getUserPermissions(userId: string): Promise<string[]> {
|
||||
const cached = this.permissionCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.permissions;
|
||||
}
|
||||
|
||||
try {
|
||||
const userRoleRecords = await db
|
||||
.select({
|
||||
permissions: roles.permissions,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
const allPermissions = new Set<string>();
|
||||
for (const record of userRoleRecords) {
|
||||
try {
|
||||
const permissions = JSON.parse(record.permissions) as string[];
|
||||
for (const perm of permissions) {
|
||||
allPermissions.add(perm);
|
||||
}
|
||||
} catch (parseError) {
|
||||
databaseLogger.warn("Failed to parse role permissions", {
|
||||
operation: "get_user_permissions",
|
||||
userId,
|
||||
error: parseError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const permissionsArray = Array.from(allPermissions);
|
||||
|
||||
this.permissionCache.set(userId, {
|
||||
permissions: permissionsArray,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return permissionsArray;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get user permissions", error, {
|
||||
operation: "get_user_permissions",
|
||||
userId,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* Supports wildcards: "hosts.*", "*"
|
||||
*/
|
||||
async hasPermission(userId: string, permission: string): Promise<boolean> {
|
||||
const userPermissions = await this.getUserPermissions(userId);
|
||||
|
||||
if (userPermissions.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userPermissions.includes(permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = permission.split(".");
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const wildcardPermission = parts.slice(0, i).join(".") + ".*";
|
||||
if (userPermissions.includes(wildcardPermission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a specific host
|
||||
*/
|
||||
async canAccessHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
): Promise<HostAccessInfo> {
|
||||
try {
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length > 0) {
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
};
|
||||
}
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const sharedAccess = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
or(
|
||||
eq(hostAccess.userId, userId),
|
||||
roleIds.length > 0
|
||||
? sql`${hostAccess.roleId} IN (${sql.join(
|
||||
roleIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`
|
||||
: sql`false`,
|
||||
),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sharedAccess.length > 0) {
|
||||
const access = sharedAccess[0];
|
||||
|
||||
if (action === "write" || action === "delete") {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
lastAccessedAt: now,
|
||||
})
|
||||
.where(eq(hostAccess.id, access.id));
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to update host access timestamp", {
|
||||
operation: "update_host_access_timestamp",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel as "view",
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check host access", error, {
|
||||
operation: "can_access_host",
|
||||
userId,
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin (backward compatibility)
|
||||
*/
|
||||
async isAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const user = await db
|
||||
.select({ isAdmin: users.is_admin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (user.length > 0 && user[0].isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const adminRoles = await db
|
||||
.select({ roleName: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, userId),
|
||||
or(eq(roles.name, "admin"), eq(roles.name, "super_admin")),
|
||||
),
|
||||
);
|
||||
|
||||
return adminRoles.length > 0;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check admin status", error, {
|
||||
operation: "is_admin",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require specific permission
|
||||
*/
|
||||
requirePermission(permission: string) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hasPermission = await this.hasPermission(userId, permission);
|
||||
|
||||
if (!hasPermission) {
|
||||
databaseLogger.warn("Permission denied", {
|
||||
operation: "permission_check",
|
||||
userId,
|
||||
permission,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: "Insufficient permissions",
|
||||
required: permission,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require host access
|
||||
*/
|
||||
requireHostAccess(
|
||||
hostIdParam: string = "id",
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params[hostIdParam], 10);
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const accessInfo = await this.canAccessHost(userId, hostId, action);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
databaseLogger.warn("Host access denied", {
|
||||
operation: "host_access_check",
|
||||
userId,
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: "Access denied to host",
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
(req as any).hostAccessInfo = accessInfo;
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require admin role (backward compatible)
|
||||
*/
|
||||
requireAdmin() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const isAdmin = await this.isAdmin(userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
databaseLogger.warn("Admin access denied", {
|
||||
operation: "admin_check",
|
||||
userId,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { PermissionManager };
|
||||
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };
|
||||
@@ -1,700 +0,0 @@
|
||||
import { db } from "../database/db/index.js";
|
||||
import {
|
||||
sharedCredentials,
|
||||
sshCredentials,
|
||||
hostAccess,
|
||||
users,
|
||||
userRoles,
|
||||
sshData,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface CredentialData {
|
||||
username: string;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages shared credentials for RBAC host sharing.
|
||||
* Creates per-user encrypted credential copies to enable credential sharing
|
||||
* without requiring the credential owner to be online.
|
||||
*/
|
||||
class SharedCredentialManager {
|
||||
private static instance: SharedCredentialManager;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SharedCredentialManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new SharedCredentialManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shared credential for a specific user
|
||||
* Called when sharing a host with a user
|
||||
*/
|
||||
async createSharedCredentialForUser(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
targetUserId: string,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
|
||||
if (ownerDEK) {
|
||||
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!targetDEK) {
|
||||
await this.createPendingSharedCredential(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
await db.insert(sharedCredentials).values({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
});
|
||||
} else {
|
||||
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!targetDEK) {
|
||||
await this.createPendingSharedCredential(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialData =
|
||||
await this.getDecryptedCredentialViaSystemKey(originalCredentialId);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
await db.insert(sharedCredentials).values({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create shared credential", error, {
|
||||
operation: "create_shared_credential",
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create shared credentials for all users in a role
|
||||
* Called when sharing a host with a role
|
||||
*/
|
||||
async createSharedCredentialsForRole(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
roleId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const roleUsers = await db
|
||||
.select({ userId: userRoles.userId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
|
||||
for (const { userId } of roleUsers) {
|
||||
try {
|
||||
await this.createSharedCredentialForUser(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credential for role member",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role",
|
||||
hostAccessId,
|
||||
roleId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to create shared credentials for role",
|
||||
error,
|
||||
{
|
||||
operation: "create_shared_credentials_role",
|
||||
hostAccessId,
|
||||
roleId,
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credential data for a shared user
|
||||
* Called when a shared user connects to a host
|
||||
*/
|
||||
async getSharedCredentialForUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<CredentialData | null> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
throw new Error(`User ${userId} data not unlocked`);
|
||||
}
|
||||
|
||||
const sharedCred = await db
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.innerJoin(
|
||||
hostAccess,
|
||||
eq(sharedCredentials.hostAccessId, hostAccess.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(sharedCredentials.targetUserId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sharedCred.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cred = sharedCred[0].shared_credentials;
|
||||
|
||||
if (cred.needsReEncryption) {
|
||||
databaseLogger.warn(
|
||||
"Shared credential needs re-encryption but cannot be accessed yet",
|
||||
{
|
||||
operation: "get_shared_credential_pending",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(cred, userDEK);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared credential", error, {
|
||||
operation: "get_shared_credential",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all shared credentials when original credential is updated
|
||||
* Called when credential owner updates credential
|
||||
*/
|
||||
async updateSharedCredentialsForOriginal(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sharedCreds = await db
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId));
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
let credentialData: CredentialData;
|
||||
|
||||
if (ownerDEK) {
|
||||
credentialData = await this.getDecryptedCredential(
|
||||
credentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
credentialData =
|
||||
await this.getDecryptedCredentialViaSystemKey(credentialId);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Cannot update shared credentials: owner offline and credential not migrated",
|
||||
{
|
||||
operation: "update_shared_credentials_failed",
|
||||
credentialId,
|
||||
ownerId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
await db
|
||||
.update(sharedCredentials)
|
||||
.set({ needsReEncryption: true })
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const sharedCred of sharedCreds) {
|
||||
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId);
|
||||
|
||||
if (!targetDEK) {
|
||||
await db
|
||||
.update(sharedCredentials)
|
||||
.set({ needsReEncryption: true })
|
||||
.where(eq(sharedCredentials.id, sharedCred.id));
|
||||
continue;
|
||||
}
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
sharedCred.targetUserId,
|
||||
targetDEK,
|
||||
sharedCred.hostAccessId,
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sharedCredentials)
|
||||
.set({
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(sharedCredentials.id, sharedCred.id));
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update shared credentials", error, {
|
||||
operation: "update_shared_credentials",
|
||||
credentialId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete shared credentials when original credential is deleted
|
||||
* Called from credential deletion route
|
||||
*/
|
||||
async deleteSharedCredentialsForOriginal(
|
||||
credentialId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await db
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.originalCredentialId, credentialId))
|
||||
.returning({ id: sharedCredentials.id });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete shared credentials", error, {
|
||||
operation: "delete_shared_credentials",
|
||||
credentialId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypt pending shared credentials for a user when they log in
|
||||
* Called during user login
|
||||
*/
|
||||
async reEncryptPendingCredentialsForUser(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingCreds = await db
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sharedCredentials.targetUserId, userId),
|
||||
eq(sharedCredentials.needsReEncryption, true),
|
||||
),
|
||||
);
|
||||
|
||||
for (const cred of pendingCreds) {
|
||||
await this.reEncryptSharedCredential(cred.id, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to re-encrypt pending credentials", error, {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getDecryptedCredential(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
ownerDEK: Buffer,
|
||||
): Promise<CredentialData> {
|
||||
const creds = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (creds.length === 0) {
|
||||
throw new Error(`Credential ${credentialId} not found`);
|
||||
}
|
||||
|
||||
const cred = creds[0];
|
||||
|
||||
return {
|
||||
username: cred.username,
|
||||
authType: cred.authType,
|
||||
password: cred.password
|
||||
? this.decryptField(cred.password, ownerDEK, credentialId, "password")
|
||||
: undefined,
|
||||
key: cred.key
|
||||
? this.decryptField(cred.key, ownerDEK, credentialId, "key")
|
||||
: undefined,
|
||||
keyPassword: cred.key_password
|
||||
? this.decryptField(
|
||||
cred.key_password,
|
||||
ownerDEK,
|
||||
credentialId,
|
||||
"key_password",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt credential using system key (for offline sharing when owner is offline)
|
||||
*/
|
||||
private async getDecryptedCredentialViaSystemKey(
|
||||
credentialId: number,
|
||||
): Promise<CredentialData> {
|
||||
const creds = await db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, credentialId))
|
||||
.limit(1);
|
||||
|
||||
if (creds.length === 0) {
|
||||
throw new Error(`Credential ${credentialId} not found`);
|
||||
}
|
||||
|
||||
const cred = creds[0];
|
||||
|
||||
if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) {
|
||||
throw new Error(
|
||||
"Credential not yet migrated for offline sharing. " +
|
||||
"Please ask credential owner to log in to enable sharing.",
|
||||
);
|
||||
}
|
||||
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const CSKEK = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
return {
|
||||
username: cred.username,
|
||||
authType: cred.authType,
|
||||
password: cred.systemPassword
|
||||
? this.decryptField(
|
||||
cred.systemPassword,
|
||||
CSKEK,
|
||||
credentialId,
|
||||
"password",
|
||||
)
|
||||
: undefined,
|
||||
key: cred.systemKey
|
||||
? this.decryptField(cred.systemKey, CSKEK, credentialId, "key")
|
||||
: undefined,
|
||||
keyPassword: cred.systemKeyPassword
|
||||
? this.decryptField(
|
||||
cred.systemKeyPassword,
|
||||
CSKEK,
|
||||
credentialId,
|
||||
"key_password",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
};
|
||||
}
|
||||
|
||||
private encryptCredentialForUser(
|
||||
credentialData: CredentialData,
|
||||
targetUserId: string,
|
||||
targetDEK: Buffer,
|
||||
hostAccessId: number,
|
||||
): {
|
||||
encryptedUsername: string;
|
||||
encryptedAuthType: string;
|
||||
encryptedPassword: string | null;
|
||||
encryptedKey: string | null;
|
||||
encryptedKeyPassword: string | null;
|
||||
encryptedKeyType: string | null;
|
||||
} {
|
||||
const recordId = `shared-${hostAccessId}-${targetUserId}`;
|
||||
|
||||
return {
|
||||
encryptedUsername: FieldCrypto.encryptField(
|
||||
credentialData.username,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"username",
|
||||
),
|
||||
encryptedAuthType: credentialData.authType,
|
||||
encryptedPassword: credentialData.password
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.password,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"password",
|
||||
)
|
||||
: null,
|
||||
encryptedKey: credentialData.key
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.key,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"key",
|
||||
)
|
||||
: null,
|
||||
encryptedKeyPassword: credentialData.keyPassword
|
||||
? FieldCrypto.encryptField(
|
||||
credentialData.keyPassword,
|
||||
targetDEK,
|
||||
recordId,
|
||||
"key_password",
|
||||
)
|
||||
: null,
|
||||
encryptedKeyType: credentialData.keyType || null,
|
||||
};
|
||||
}
|
||||
|
||||
private decryptSharedCredential(
|
||||
sharedCred: typeof sharedCredentials.$inferSelect,
|
||||
userDEK: Buffer,
|
||||
): CredentialData {
|
||||
const recordId = `shared-${sharedCred.hostAccessId}-${sharedCred.targetUserId}`;
|
||||
|
||||
return {
|
||||
username: FieldCrypto.decryptField(
|
||||
sharedCred.encryptedUsername,
|
||||
userDEK,
|
||||
recordId,
|
||||
"username",
|
||||
),
|
||||
authType: sharedCred.encryptedAuthType,
|
||||
password: sharedCred.encryptedPassword
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedPassword,
|
||||
userDEK,
|
||||
recordId,
|
||||
"password",
|
||||
)
|
||||
: undefined,
|
||||
key: sharedCred.encryptedKey
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedKey,
|
||||
userDEK,
|
||||
recordId,
|
||||
"key",
|
||||
)
|
||||
: undefined,
|
||||
keyPassword: sharedCred.encryptedKeyPassword
|
||||
? FieldCrypto.decryptField(
|
||||
sharedCred.encryptedKeyPassword,
|
||||
userDEK,
|
||||
recordId,
|
||||
"key_password",
|
||||
)
|
||||
: undefined,
|
||||
keyType: sharedCred.encryptedKeyType || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private decryptField(
|
||||
encryptedValue: string,
|
||||
dek: Buffer,
|
||||
recordId: number | string,
|
||||
fieldName: string,
|
||||
): string {
|
||||
try {
|
||||
return FieldCrypto.decryptField(
|
||||
encryptedValue,
|
||||
dek,
|
||||
recordId.toString(),
|
||||
fieldName,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Field decryption failed, returning as-is", {
|
||||
operation: "decrypt_field",
|
||||
fieldName,
|
||||
recordId,
|
||||
});
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async createPendingSharedCredential(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
await db.insert(sharedCredentials).values({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
encryptedUsername: "",
|
||||
encryptedAuthType: "",
|
||||
needsReEncryption: true,
|
||||
});
|
||||
|
||||
databaseLogger.info("Created pending shared credential", {
|
||||
operation: "create_pending_shared_credential",
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
});
|
||||
}
|
||||
|
||||
private async reEncryptSharedCredential(
|
||||
sharedCredId: number,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sharedCred = await db
|
||||
.select()
|
||||
.from(sharedCredentials)
|
||||
.where(eq(sharedCredentials.id, sharedCredId))
|
||||
.limit(1);
|
||||
|
||||
if (sharedCred.length === 0) {
|
||||
databaseLogger.warn("Re-encrypt: shared credential not found", {
|
||||
operation: "reencrypt_not_found",
|
||||
sharedCredId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cred = sharedCred[0];
|
||||
|
||||
const access = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.where(eq(hostAccess.id, cred.hostAccessId))
|
||||
.limit(1);
|
||||
|
||||
if (access.length === 0) {
|
||||
databaseLogger.warn("Re-encrypt: host access not found", {
|
||||
operation: "reencrypt_access_not_found",
|
||||
sharedCredId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId = access[0].ssh_data.userId;
|
||||
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
databaseLogger.warn("Re-encrypt: user DEK not available", {
|
||||
operation: "reencrypt_user_offline",
|
||||
sharedCredId,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
let credentialData: CredentialData;
|
||||
|
||||
if (ownerDEK) {
|
||||
credentialData = await this.getDecryptedCredential(
|
||||
cred.originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
credentialData = await this.getDecryptedCredentialViaSystemKey(
|
||||
cred.originalCredentialId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Re-encrypt: system key decryption failed, credential may not be migrated yet",
|
||||
{
|
||||
operation: "reencrypt_system_key_failed",
|
||||
sharedCredId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
userId,
|
||||
userDEK,
|
||||
cred.hostAccessId,
|
||||
);
|
||||
|
||||
await db
|
||||
.update(sharedCredentials)
|
||||
.set({
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(sharedCredentials.id, sharedCredId));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to re-encrypt shared credential", error, {
|
||||
operation: "reencrypt_shared_credential",
|
||||
sharedCredId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SharedCredentialManager };
|
||||
@@ -2,12 +2,7 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName =
|
||||
| "users"
|
||||
| "ssh_data"
|
||||
| "ssh_credentials"
|
||||
| "recent_activity"
|
||||
| "socks5_proxy_presets";
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
@@ -28,20 +23,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
dataWithTempId,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
@@ -124,20 +105,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
data,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { SocksClient } from "socks";
|
||||
import type { SocksClientOptions } from "socks";
|
||||
import net from "net";
|
||||
import { sshLogger } from "./logger.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
|
||||
export interface SOCKS5Config {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SOCKS5 connection through a single proxy or a chain of proxies
|
||||
* @param targetHost - Target SSH server hostname/IP
|
||||
* @param targetPort - Target SSH server port
|
||||
* @param socks5Config - SOCKS5 proxy configuration
|
||||
* @returns Promise with connected socket or null if SOCKS5 is not enabled
|
||||
*/
|
||||
export async function createSocks5Connection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
socks5Config: SOCKS5Config,
|
||||
): Promise<net.Socket | null> {
|
||||
if (!socks5Config.useSocks5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
socks5Config.socks5ProxyChain &&
|
||||
socks5Config.socks5ProxyChain.length > 0
|
||||
) {
|
||||
return createProxyChainConnection(
|
||||
targetHost,
|
||||
targetPort,
|
||||
socks5Config.socks5ProxyChain,
|
||||
);
|
||||
}
|
||||
|
||||
if (socks5Config.socks5Host) {
|
||||
return createSingleProxyConnection(targetHost, targetPort, socks5Config);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection through a single SOCKS proxy
|
||||
*/
|
||||
async function createSingleProxyConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
socks5Config: SOCKS5Config,
|
||||
): Promise<net.Socket> {
|
||||
const socksOptions: SocksClientOptions = {
|
||||
proxy: {
|
||||
host: socks5Config.socks5Host!,
|
||||
port: socks5Config.socks5Port || 1080,
|
||||
type: 5,
|
||||
userId: socks5Config.socks5Username,
|
||||
password: socks5Config.socks5Password,
|
||||
},
|
||||
command: "connect",
|
||||
destination: {
|
||||
host: targetHost,
|
||||
port: targetPort,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await SocksClient.createConnection(socksOptions);
|
||||
|
||||
return info.socket;
|
||||
} catch (error) {
|
||||
sshLogger.error("SOCKS5 connection failed", error, {
|
||||
operation: "socks5_connect_failed",
|
||||
proxyHost: socks5Config.socks5Host,
|
||||
proxyPort: socks5Config.socks5Port || 1080,
|
||||
targetHost,
|
||||
targetPort,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a connection through a chain of SOCKS proxies
|
||||
* Each proxy in the chain connects through the previous one
|
||||
*/
|
||||
async function createProxyChainConnection(
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
proxyChain: ProxyNode[],
|
||||
): Promise<net.Socket> {
|
||||
if (proxyChain.length === 0) {
|
||||
throw new Error("Proxy chain is empty");
|
||||
}
|
||||
|
||||
const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → ");
|
||||
try {
|
||||
const info = await SocksClient.createConnectionChain({
|
||||
proxies: proxyChain.map((p) => ({
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
type: p.type,
|
||||
userId: p.username,
|
||||
password: p.password,
|
||||
timeout: 10000,
|
||||
})),
|
||||
command: "connect",
|
||||
destination: {
|
||||
host: targetHost,
|
||||
port: targetPort,
|
||||
},
|
||||
});
|
||||
return info.socket;
|
||||
} catch (error) {
|
||||
sshLogger.error("SOCKS proxy chain connection failed", error, {
|
||||
operation: "socks5_chain_connect_failed",
|
||||
chainLength: proxyChain.length,
|
||||
targetHost,
|
||||
targetPort,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ class SystemCrypto {
|
||||
private jwtSecret: string | null = null;
|
||||
private databaseKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -159,48 +158,6 @@ class SystemCrypto {
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
async initializeCredentialSharingKey(): Promise<void> {
|
||||
try {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.CREDENTIAL_SHARING_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m);
|
||||
if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex");
|
||||
process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch (fileError) {}
|
||||
|
||||
await this.generateAndGuideCredentialSharingKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to initialize credential sharing key",
|
||||
error,
|
||||
{
|
||||
operation: "cred_sharing_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
},
|
||||
);
|
||||
throw new Error("Credential sharing key initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getCredentialSharingKey(): Promise<Buffer> {
|
||||
if (!this.credentialSharingKey) {
|
||||
await this.initializeCredentialSharingKey();
|
||||
}
|
||||
return this.credentialSharingKey!;
|
||||
}
|
||||
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
@@ -253,26 +210,6 @@ class SystemCrypto {
|
||||
);
|
||||
}
|
||||
|
||||
private async generateAndGuideCredentialSharingKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.credentialSharingKey = newKey;
|
||||
|
||||
await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success(
|
||||
"Credential sharing key auto-generated and saved to .env",
|
||||
{
|
||||
operation: "cred_sharing_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "CREDENTIAL_SHARING_KEY",
|
||||
note: "Used for offline credential sharing - no restart required",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-overlay",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -15,7 +15,7 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-elevated text-foreground flex flex-col gap-6 rounded-lg border-2 border-edge py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -90,7 +90,7 @@ function CommandList({
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto thin-scrollbar",
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -36,7 +36,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-overlay",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-elevated dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -37,13 +37,13 @@ function ResizableHandle({
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-edge-hover hover:bg-interact active:bg-pressed transition-colors duration-150",
|
||||
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-edge-hover hover:bg-interact active:bg-pressed z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
||||
<div className="bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto thin-scrollbar rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -34,7 +34,7 @@ function SheetOverlay({
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-overlay data-[state=closed]:pointer-events-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -365,7 +365,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto thin-scrollbar group-data-[collapsible=icon]:overflow-hidden",
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -51,7 +51,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-elevated shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps, toast } from "sonner";
|
||||
import { useRef } from "react";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto thin-scrollbar"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -56,62 +56,6 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
},
|
||||
},
|
||||
|
||||
termixDark: {
|
||||
name: "Termix Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#18181b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#18181b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixLight: {
|
||||
name: "Termix Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#ffffff",
|
||||
foreground: "#18181b",
|
||||
cursor: "#18181b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#d1d5db",
|
||||
black: "#18181b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#f4f4f5",
|
||||
brightBlack: "#71717a",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
name: "Dracula",
|
||||
category: "dark",
|
||||
|
||||
@@ -36,55 +36,24 @@ export function useConfirmation() {
|
||||
};
|
||||
|
||||
const confirmWithToast = (
|
||||
opts: ConfirmationOptions | string,
|
||||
callback?: () => void,
|
||||
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
|
||||
cancelLabel: string = "Cancel",
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
const isVariant =
|
||||
variantOrConfirmLabel === "default" ||
|
||||
variantOrConfirmLabel === "destructive";
|
||||
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
|
||||
message: string,
|
||||
callback: () => void,
|
||||
variant: "default" | "destructive" = "default",
|
||||
) => {
|
||||
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||
const cancelText = "Cancel";
|
||||
|
||||
if (typeof opts === "string") {
|
||||
toast(opts, {
|
||||
action: {
|
||||
label: confirmLabel,
|
||||
onClick: () => {
|
||||
if (callback) callback();
|
||||
resolve(true);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: cancelLabel,
|
||||
onClick: () => {
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} else if (typeof opts === "object") {
|
||||
const actualConfirmLabel = opts.confirmText || confirmLabel;
|
||||
const actualCancelLabel = opts.cancelText || cancelLabel;
|
||||
|
||||
toast(opts.description, {
|
||||
action: {
|
||||
label: actualConfirmLabel,
|
||||
onClick: () => {
|
||||
if (callback) callback();
|
||||
resolve(true);
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: actualCancelLabel,
|
||||
onClick: () => {
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
toast(message, {
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: callback,
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 10000,
|
||||
className: variant === "destructive" ? "border-red-500" : "",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
123
src/i18n/i18n.ts
123
src/i18n/i18n.ts
@@ -2,65 +2,19 @@ import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import enTranslation from "../locales/en.json";
|
||||
import zhTranslation from "../locales/zh.json";
|
||||
import deTranslation from "../locales/de.json";
|
||||
import ptTranslation from "../locales/pt.json";
|
||||
import ruTranslation from "../locales/ru.json";
|
||||
import frTranslation from "../locales/fr.json";
|
||||
import koTranslation from "../locales/ko.json";
|
||||
import itTranslation from "../locales/it.json";
|
||||
import esTranslation from "../locales/es.json";
|
||||
import hiTranslation from "../locales/hi.json";
|
||||
import bnTranslation from "../locales/bn.json";
|
||||
import jaTranslation from "../locales/ja.json";
|
||||
import viTranslation from "../locales/vi.json";
|
||||
import trTranslation from "../locales/tr.json";
|
||||
import heTranslation from "../locales/he.json";
|
||||
import arTranslation from "../locales/ar.json";
|
||||
import plTranslation from "../locales/pl.json";
|
||||
import nlTranslation from "../locales/nl.json";
|
||||
import svTranslation from "../locales/sv.json";
|
||||
import idTranslation from "../locales/id.json";
|
||||
import thTranslation from "../locales/th.json";
|
||||
import ukTranslation from "../locales/uk.json";
|
||||
import csTranslation from "../locales/cs.json";
|
||||
import roTranslation from "../locales/ro.json";
|
||||
import elTranslation from "../locales/el.json";
|
||||
import nbTranslation from "../locales/nb.json";
|
||||
import enTranslation from "../locales/en/translation.json";
|
||||
import zhTranslation from "../locales/zh/translation.json";
|
||||
import deTranslation from "../locales/de/translation.json";
|
||||
import ptbrTranslation from "../locales/pt-BR/translation.json";
|
||||
import ruTranslation from "../locales/ru/translation.json";
|
||||
import frTranslation from "../locales/fr/translation.json";
|
||||
import koTranslation from "../locales/ko/translation.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: [
|
||||
"en",
|
||||
"zh",
|
||||
"de",
|
||||
"pt",
|
||||
"ru",
|
||||
"fr",
|
||||
"ko",
|
||||
"it",
|
||||
"es",
|
||||
"hi",
|
||||
"bn",
|
||||
"ja",
|
||||
"vi",
|
||||
"tr",
|
||||
"he",
|
||||
"ar",
|
||||
"pl",
|
||||
"nl",
|
||||
"sv",
|
||||
"id",
|
||||
"th",
|
||||
"uk",
|
||||
"cs",
|
||||
"ro",
|
||||
"el",
|
||||
"nb",
|
||||
],
|
||||
supportedLngs: ["en", "zh", "de", "ptbr", "ru", "fr", "ko"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
@@ -82,8 +36,8 @@ i18n
|
||||
de: {
|
||||
translation: deTranslation,
|
||||
},
|
||||
pt: {
|
||||
translation: ptTranslation,
|
||||
ptbr: {
|
||||
translation: ptbrTranslation,
|
||||
},
|
||||
ru: {
|
||||
translation: ruTranslation,
|
||||
@@ -94,63 +48,6 @@ i18n
|
||||
ko: {
|
||||
translation: koTranslation,
|
||||
},
|
||||
it: {
|
||||
translation: itTranslation,
|
||||
},
|
||||
es: {
|
||||
translation: esTranslation,
|
||||
},
|
||||
hi: {
|
||||
translation: hiTranslation,
|
||||
},
|
||||
bn: {
|
||||
translation: bnTranslation,
|
||||
},
|
||||
ja: {
|
||||
translation: jaTranslation,
|
||||
},
|
||||
vi: {
|
||||
translation: viTranslation,
|
||||
},
|
||||
tr: {
|
||||
translation: trTranslation,
|
||||
},
|
||||
he: {
|
||||
translation: heTranslation,
|
||||
},
|
||||
ar: {
|
||||
translation: arTranslation,
|
||||
},
|
||||
pl: {
|
||||
translation: plTranslation,
|
||||
},
|
||||
nl: {
|
||||
translation: nlTranslation,
|
||||
},
|
||||
sv: {
|
||||
translation: svTranslation,
|
||||
},
|
||||
id: {
|
||||
translation: idTranslation,
|
||||
},
|
||||
th: {
|
||||
translation: thTranslation,
|
||||
},
|
||||
uk: {
|
||||
translation: ukTranslation,
|
||||
},
|
||||
cs: {
|
||||
translation: csTranslation,
|
||||
},
|
||||
ro: {
|
||||
translation: roTranslation,
|
||||
},
|
||||
el: {
|
||||
translation: elTranslation,
|
||||
},
|
||||
nb: {
|
||||
translation: nbTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
|
||||
264
src/index.css
264
src/index.css
@@ -8,76 +8,45 @@
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: var(--foreground);
|
||||
background-color: var(--bg-base);
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #09090b;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--radius: 0.625rem;
|
||||
--background: #ffffff;
|
||||
--foreground: #18181b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18181b;
|
||||
--primary: #27272a;
|
||||
--primary-foreground: #fafafa;
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #27272a;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--accent: #f4f4f5;
|
||||
--accent-foreground: #27272a;
|
||||
--destructive: #dc2626;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--ring: #a1a1aa;
|
||||
--chart-1: #e76e50;
|
||||
--chart-2: #2a9d8f;
|
||||
--chart-3: #264653;
|
||||
--chart-4: #e9c46a;
|
||||
--chart-5: #f4a261;
|
||||
--sidebar: #f9f9f9;
|
||||
--sidebar-foreground: #18181b;
|
||||
--sidebar-primary: #27272a;
|
||||
--sidebar-primary-foreground: #fafafa;
|
||||
--sidebar-accent: #f4f4f5;
|
||||
--sidebar-accent-foreground: #27272a;
|
||||
--sidebar-border: #e4e4e7;
|
||||
--sidebar-ring: #a1a1aa;
|
||||
|
||||
--bg-base: #fcfcfc;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-surface: #f3f4f6;
|
||||
--bg-surface-hover: #e5e7eb;
|
||||
--bg-input: #ffffff;
|
||||
--bg-deepest: #e5e7eb;
|
||||
--bg-header: #eeeeef;
|
||||
--bg-button: #f3f4f6;
|
||||
--bg-active: #e5e7eb;
|
||||
--bg-light: #fafafa;
|
||||
--bg-subtle: #f5f5f5;
|
||||
--bg-interact: #d1d5db;
|
||||
--border-base: #e5e7eb;
|
||||
--border-panel: #d1d5db;
|
||||
--border-subtle: #f3f4f6;
|
||||
--border-medium: #d1d5db;
|
||||
--bg-hover: #f3f4f6;
|
||||
--bg-hover-alt: #e5e7eb;
|
||||
--bg-pressed: #d1d5db;
|
||||
--border-hover: #d1d5db;
|
||||
--border-active: #9ca3af;
|
||||
|
||||
--foreground-secondary: #334155;
|
||||
--foreground-subtle: #94a3b8;
|
||||
|
||||
--scrollbar-thumb: #c1c1c3;
|
||||
--scrollbar-thumb-hover: #a1a1a3;
|
||||
--scrollbar-track: #f3f4f6;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -138,99 +107,40 @@
|
||||
--color-dark-bg-panel: #1b1b1e;
|
||||
--color-dark-border-panel: #222224;
|
||||
--color-dark-bg-panel-hover: #232327;
|
||||
|
||||
--color-canvas: var(--bg-base);
|
||||
--color-elevated: var(--bg-elevated);
|
||||
--color-surface: var(--bg-surface);
|
||||
--color-surface-hover: var(--bg-surface-hover);
|
||||
--color-field: var(--bg-input);
|
||||
--color-deepest: var(--bg-deepest);
|
||||
--color-header: var(--bg-header);
|
||||
--color-button: var(--bg-button);
|
||||
--color-active: var(--bg-active);
|
||||
--color-light: var(--bg-light);
|
||||
--color-subtle: var(--bg-subtle);
|
||||
--color-interact: var(--bg-interact);
|
||||
--color-hover: var(--bg-hover);
|
||||
--color-hover-alt: var(--bg-hover-alt);
|
||||
--color-pressed: var(--bg-pressed);
|
||||
|
||||
--color-edge: var(--border-base);
|
||||
--color-edge-panel: var(--border-panel);
|
||||
--color-edge-subtle: var(--border-subtle);
|
||||
--color-edge-medium: var(--border-medium);
|
||||
--color-edge-hover: var(--border-hover);
|
||||
--color-edge-active: var(--border-active);
|
||||
|
||||
--color-foreground-secondary: var(--foreground-secondary);
|
||||
--color-foreground-subtle: var(--foreground-subtle);
|
||||
|
||||
--color-overlay: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #18181b;
|
||||
--card-foreground: #fafafa;
|
||||
--popover: #27272a;
|
||||
--popover-foreground: #fafafa;
|
||||
--primary: #e4e4e7;
|
||||
--primary-foreground: #27272a;
|
||||
--secondary: #3f3f46;
|
||||
--secondary-foreground: #fafafa;
|
||||
--muted: #27272a;
|
||||
--muted-foreground: #9ca3af;
|
||||
--accent: #3f3f46;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #f87171;
|
||||
--border: #ffffff1a;
|
||||
--input: #ffffff26;
|
||||
--ring: #71717a;
|
||||
--chart-1: #3b82f6;
|
||||
--chart-2: #34d399;
|
||||
--chart-3: #f4a261;
|
||||
--chart-4: #a855f7;
|
||||
--chart-5: #f43f5e;
|
||||
--sidebar: #18181b;
|
||||
--sidebar-foreground: #fafafa;
|
||||
--sidebar-primary: #3b82f6;
|
||||
--sidebar-primary-foreground: #fafafa;
|
||||
--sidebar-accent: #3f3f46;
|
||||
--sidebar-accent-foreground: #fafafa;
|
||||
--sidebar-border: #ffffff1a;
|
||||
--sidebar-ring: #71717a;
|
||||
|
||||
--bg-base: #18181b;
|
||||
--bg-elevated: #0e0e10;
|
||||
--bg-surface: #1b1b1e;
|
||||
--bg-surface-hover: #232327;
|
||||
--bg-input: #222225;
|
||||
--bg-deepest: #09090b;
|
||||
--bg-header: #131316;
|
||||
--bg-button: #23232a;
|
||||
--bg-active: #1d1d1f;
|
||||
--bg-light: #141416;
|
||||
--bg-subtle: #101014;
|
||||
--bg-interact: #2a2a2c;
|
||||
--border-base: #303032;
|
||||
--border-panel: #222224;
|
||||
--border-subtle: #5a5a5d;
|
||||
--border-medium: #373739;
|
||||
--bg-hover: #2d2d30;
|
||||
--bg-hover-alt: #2a2a2d;
|
||||
--bg-pressed: #1a1a1c;
|
||||
--border-hover: #434345;
|
||||
--border-active: #2d2d30;
|
||||
|
||||
--foreground-secondary: #d1d5db;
|
||||
--foreground-subtle: #6b7280;
|
||||
|
||||
--scrollbar-thumb: #434345;
|
||||
--scrollbar-thumb-hover: #5a5a5d;
|
||||
--scrollbar-track: #18181b;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -250,7 +160,23 @@
|
||||
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
scrollbar-color: #303032 transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #303032;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
@@ -259,37 +185,19 @@
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
background: #434345;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background: #5a5a5d;
|
||||
}
|
||||
|
||||
.skinny-scrollbar {
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skinny-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
scrollbar-color: #434345 #18181b;
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
const ANSI_CODES = {
|
||||
reset: "\x1b[0m",
|
||||
colors: {
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightMagenta: "\x1b[95m",
|
||||
brightCyan: "\x1b[96m",
|
||||
brightWhite: "\x1b[97m",
|
||||
},
|
||||
styles: {
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
italic: "\x1b[3m",
|
||||
underline: "\x1b[4m",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
quickCheck?: string;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
start: number;
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 5000;
|
||||
const MAX_ANSI_CODES = 10;
|
||||
|
||||
const PATTERNS: HighlightPattern[] = [
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI_CODES.colors.magenta,
|
||||
priority: 10,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(ERROR|FATAL|CRITICAL|FAIL(?:ED)?|denied|invalid|DENIED)\b|\[ERROR\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightRed,
|
||||
priority: 9,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(WARN(?:ING)?|ALERT)\b|\[WARN(?:ING)?\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.yellow,
|
||||
priority: 9,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(SUCCESS|OK|PASS(?:ED)?|COMPLETE(?:D)?|connected|active|up|Up|UP|FULL)\b/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightGreen,
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}]+/g,
|
||||
ansiCode: `${ANSI_CODES.colors.blue}${ANSI_CODES.styles.underline}`,
|
||||
priority: 8,
|
||||
},
|
||||
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex: /\/[a-zA-Z][a-zA-Z0-9_\-@.]*(?:\/[a-zA-Z0-9_\-@.]+)+/g,
|
||||
ansiCode: ANSI_CODES.colors.cyan,
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[a-zA-Z0-9_\-@./]+/g,
|
||||
ansiCode: ANSI_CODES.colors.cyan,
|
||||
priority: 7,
|
||||
},
|
||||
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\bINFO\b|\[INFO\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.blue,
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:DEBUG|TRACE)\b|\[(?:DEBUG|TRACE)\]/gi,
|
||||
ansiCode: ANSI_CODES.colors.brightBlack,
|
||||
priority: 6,
|
||||
},
|
||||
];
|
||||
|
||||
function hasExistingAnsiCodes(text: string): boolean {
|
||||
const ansiCount = (
|
||||
text.match(
|
||||
/\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
|
||||
) || []
|
||||
).length;
|
||||
return ansiCount > MAX_ANSI_CODES;
|
||||
}
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text);
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = ansiRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
isAnsi: true,
|
||||
content: match[0],
|
||||
});
|
||||
|
||||
lastIndex = ansiRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(text: string): string {
|
||||
if (text.length > MAX_LINE_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = pattern.regex.exec(text)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
matches.sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
return a.start - b.start;
|
||||
});
|
||||
|
||||
const appliedRanges: Array<{ start: number; end: number }> = [];
|
||||
const finalMatches = matches.filter((match) => {
|
||||
const overlaps = appliedRanges.some(
|
||||
(range) =>
|
||||
(match.start >= range.start && match.start < range.end) ||
|
||||
(match.end > range.start && match.end <= range.end) ||
|
||||
(match.start <= range.start && match.end >= range.end),
|
||||
);
|
||||
|
||||
if (!overlaps) {
|
||||
appliedRanges.push({ start: match.start, end: match.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
let result = text;
|
||||
finalMatches.reverse().forEach((match) => {
|
||||
const before = result.slice(0, match.start);
|
||||
const matched = result.slice(match.start, match.end);
|
||||
const after = result.slice(match.end);
|
||||
|
||||
result = before + match.ansiCode + matched + ANSI_CODES.reset + after;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(text: string): string {
|
||||
if (!text || !text.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (hasIncompleteAnsiSequence(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (hasExistingAnsiCodes(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const segments = parseAnsiSegments(text);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return highlightPlainText(text);
|
||||
}
|
||||
|
||||
const highlightedSegments = segments.map((segment) => {
|
||||
if (segment.isAnsi) {
|
||||
return segment.content;
|
||||
} else {
|
||||
return highlightPlainText(segment.content);
|
||||
}
|
||||
});
|
||||
|
||||
return highlightedSegments.join("");
|
||||
}
|
||||
|
||||
export function isSyntaxHighlightingEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
2402
src/locales/ar.json
2402
src/locales/ar.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/bn.json
2402
src/locales/bn.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/cs.json
2402
src/locales/cs.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/de.json
2402
src/locales/de.json
File diff suppressed because it is too large
Load Diff
1785
src/locales/de/translation.json
Normal file
1785
src/locales/de/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/el.json
2402
src/locales/el.json
File diff suppressed because it is too large
Load Diff
@@ -160,29 +160,12 @@
|
||||
"generateEd25519": "Generate Ed25519",
|
||||
"generateECDSA": "Generate ECDSA",
|
||||
"generateRSA": "Generate RSA",
|
||||
"keyTypeEcdsaP256": "ECDSA P-256 (SSH)",
|
||||
"keyTypeEcdsaP384": "ECDSA P-384 (SSH)",
|
||||
"keyTypeEcdsaP521": "ECDSA P-521 (SSH)",
|
||||
"keyTypeDsa": "DSA (SSH)",
|
||||
"keyTypeRsaSha256": "RSA-SHA2-256",
|
||||
"keyTypeRsaSha512": "RSA-SHA2-512",
|
||||
"keyPairGeneratedSuccessfully": "{{keyType}} key pair generated successfully",
|
||||
"failedToGenerateKeyPair": "Failed to generate key pair",
|
||||
"generateKeyPairNote": "Generate a new SSH key pair directly. This will replace any existing keys in the form.",
|
||||
"invalidKey": "Invalid Key",
|
||||
"detectionError": "Detection Error",
|
||||
"removing": "Removing:",
|
||||
"clickToEditCredential": "Click to edit credential",
|
||||
"dragToMoveBetweenFolders": "Drag to move between folders",
|
||||
"keyBasedOnlyForDeployment": "Only SSH key-based credentials can be deployed",
|
||||
"publicKeyRequiredForDeployment": "Public key is required for deployment",
|
||||
"selectTargetHost": "Please select a target host",
|
||||
"keyDeployedSuccessfully": "SSH key deployed successfully",
|
||||
"deploymentFailed": "Deployment failed",
|
||||
"failedToDeployKey": "Failed to deploy SSH key",
|
||||
"clickToRenameFolder": "Click to rename folder",
|
||||
"renameFolder": "Rename folder",
|
||||
"idLabel": "ID:"
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"dragIndicator": {
|
||||
"error": "Error: {{error}}",
|
||||
@@ -205,10 +188,7 @@
|
||||
"commandsWillBeSent": "Commands will be sent to {{count}} selected terminal(s).",
|
||||
"settings": "Settings",
|
||||
"enableRightClickCopyPaste": "Enable right‑click copy/paste",
|
||||
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on",
|
||||
"scripts": {
|
||||
"inputPlaceholder": "e.g., System Commands, Docker Scripts"
|
||||
}
|
||||
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
@@ -218,7 +198,6 @@
|
||||
"run": "Run",
|
||||
"empty": "No snippets yet",
|
||||
"emptyHint": "Create a snippet to save commonly used commands",
|
||||
"searchSnippets": "Search snippets...",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"content": "Command",
|
||||
@@ -243,34 +222,7 @@
|
||||
"runTooltip": "Execute this snippet in the terminal",
|
||||
"copyTooltip": "Copy snippet to clipboard",
|
||||
"editTooltip": "Edit this snippet",
|
||||
"deleteTooltip": "Delete this snippet",
|
||||
"newFolder": "New Folder",
|
||||
"reorderSameFolder": "Can only reorder snippets within the same folder",
|
||||
"reorderSuccess": "Snippets reordered successfully",
|
||||
"reorderFailed": "Failed to reorder snippets",
|
||||
"deleteFolderConfirm": "Delete folder \"{{name}}\"? All snippets will be moved to Uncategorized.",
|
||||
"deleteFolderSuccess": "Folder deleted successfully",
|
||||
"deleteFolderFailed": "Failed to delete folder",
|
||||
"updateFolderSuccess": "Folder updated successfully",
|
||||
"createFolderSuccess": "Folder created successfully",
|
||||
"updateFolderFailed": "Failed to update folder",
|
||||
"createFolderFailed": "Failed to create folder",
|
||||
"selectTerminals": "Select Terminals (optional)",
|
||||
"executeOnSelected": "Execute on {{count}} selected terminal(s)",
|
||||
"executeOnCurrent": "Execute on current terminal (click to select multiple)",
|
||||
"folder": "Folder",
|
||||
"selectFolder": "Select a folder or leave empty",
|
||||
"noFolder": "No folder (Uncategorized)",
|
||||
"folderName": "Folder Name",
|
||||
"folderNameRequired": "Folder name is required",
|
||||
"folderColor": "Folder Color",
|
||||
"folderIcon": "Folder Icon",
|
||||
"preview": "Preview",
|
||||
"updateFolder": "Update Folder",
|
||||
"createFolder": "Create Folder",
|
||||
"editFolder": "Edit Folder",
|
||||
"editFolderDescription": "Customize your snippet folder",
|
||||
"createFolderDescription": "Organize your snippets into folders"
|
||||
"deleteTooltip": "Delete this snippet"
|
||||
},
|
||||
"commandHistory": {
|
||||
"title": "History",
|
||||
@@ -284,32 +236,7 @@
|
||||
"deleteSuccess": "Command deleted from history",
|
||||
"deleteFailed": "Failed to delete command.",
|
||||
"deleteTooltip": "Delete command",
|
||||
"tabHint": "Use Tab in Terminal to autocomplete from command history if enabled in User Profile",
|
||||
"authRequiredRefresh": "Authentication required. Please refresh the page.",
|
||||
"dataAccessLockedReauth": "Data access locked. Please re-authenticate.",
|
||||
"loading": "Loading command history...",
|
||||
"error": "Error Loading History"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Split Screen",
|
||||
"none": "None",
|
||||
"twoSplit": "2-Way",
|
||||
"threeSplit": "3-Way",
|
||||
"fourSplit": "4-Way",
|
||||
"availableTabs": "Available Tabs",
|
||||
"dragTabsHint": "Drag tabs to the layout cells below to assign them",
|
||||
"layout": "Split Screen Layout",
|
||||
"dropHere": "Drop tab here",
|
||||
"apply": "Apply Split",
|
||||
"clear": "Clear Split",
|
||||
"selectMode": "Select a split screen mode",
|
||||
"helpText": "Choose how many tabs you want to view at once",
|
||||
"success": "Split screen applied successfully",
|
||||
"cleared": "Split screen cleared",
|
||||
"error": {
|
||||
"noAssignments": "Please assign at least one tab to the layout",
|
||||
"fillAllSlots": "Please fill all {{count}} slots before applying"
|
||||
}
|
||||
"tabHint": "Use Tab in Terminal to autocomplete from command history"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "Logged in!",
|
||||
@@ -375,20 +302,17 @@
|
||||
"optional": "Optional",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"creating": "Creating...",
|
||||
"clear": "Clear",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"sidebar": "Sidebar",
|
||||
"home": "Home",
|
||||
"expired": "Expired",
|
||||
"expiresToday": "Expires today",
|
||||
"expiresTomorrow": "Expires in {{days}} days",
|
||||
"expiresTomorrow": "Expires tomorrow",
|
||||
"expiresInDays": "Expires in {{days}} days",
|
||||
"updateAvailable": "Update Available",
|
||||
"sshPath": "SSH Path",
|
||||
"localPath": "Local Path",
|
||||
"appName": "Termix",
|
||||
"resetSidebarWidth": "Reset sidebar width",
|
||||
"dragToResizeSidebar": "Drag to resize sidebar",
|
||||
"noAuthCredentials": "No authentication credentials available for this SSH host",
|
||||
"noReleases": "No Releases",
|
||||
"updatesAndReleases": "Updates & Releases",
|
||||
@@ -463,18 +387,13 @@
|
||||
"documentation": "Documentation",
|
||||
"retry": "Retry",
|
||||
"checking": "Checking...",
|
||||
"checkingDatabase": "Checking database connection...",
|
||||
"actions": "Actions",
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
"create": "Create"
|
||||
"checkingDatabase": "Checking database connection..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"hosts": "Hosts",
|
||||
"credentials": "Credentials",
|
||||
"terminal": "Terminal",
|
||||
"docker": "Docker",
|
||||
"tunnels": "Tunnels",
|
||||
"fileManager": "File Manager",
|
||||
"serverStats": "Server Stats",
|
||||
@@ -488,8 +407,7 @@
|
||||
"sshManager": "SSH Manager",
|
||||
"hostManager": "Host Manager",
|
||||
"cannotSplitTab": "Cannot split this tab",
|
||||
"tabNavigation": "Tab Navigation",
|
||||
"hostTabTitle": "{{username}}@{{ip}}:{{port}}"
|
||||
"tabNavigation": "Tab Navigation"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
@@ -565,46 +483,6 @@
|
||||
"linkToPasswordAccount": "Link to Password Account",
|
||||
"linkOIDCDialogTitle": "Link OIDC Account to Password Account",
|
||||
"linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
|
||||
"createUser": "Create User",
|
||||
"createUserDescription": "Create a new local user with username and password",
|
||||
"enterUsername": "Enter username",
|
||||
"enterPassword": "Enter password",
|
||||
"userCreatedSuccessfully": "User {{username}} created successfully",
|
||||
"failedToCreateUser": "Failed to create user",
|
||||
"manageUser": "Manage User",
|
||||
"manageUserDescription": "Manage user settings, roles, and permissions",
|
||||
"authType": "Authentication Type",
|
||||
"adminStatus": "Admin Status",
|
||||
"userId": "User ID",
|
||||
"regularUser": "Regular User",
|
||||
"adminPrivileges": "Administrator Privileges",
|
||||
"administratorRole": "Administrator Role",
|
||||
"administratorRoleDescription": "Grant full system access and management privileges",
|
||||
"passwordManagement": "Password Management",
|
||||
"passwordResetWarning": "Resetting a user's password will delete all their data (SSH hosts, credentials, settings). This action cannot be undone.",
|
||||
"resetUserPassword": "Reset User Password",
|
||||
"resettingPassword": "Resetting...",
|
||||
"passwordResetInitiated": "Password reset initiated for {{username}}. Reset code sent.",
|
||||
"failedToResetPassword": "Failed to initiate password reset",
|
||||
"sessionManagement": "Session Management",
|
||||
"revokeAllSessions": "Revoke All Sessions",
|
||||
"revokeAllSessionsDescription": "Force logout from all devices and sessions",
|
||||
"revoking": "Revoking...",
|
||||
"revoke": "Revoke All",
|
||||
"dangerZone": "Danger Zone",
|
||||
"deleteUserTitle": "Delete User Account",
|
||||
"deleteUserWarning": "Permanently delete this user account and all associated data. This action cannot be undone.",
|
||||
"deleting": "Deleting...",
|
||||
"cannotDeleteSelf": "You cannot delete your own account",
|
||||
"cannotRemoveLastAdmin": "Cannot remove the last administrator",
|
||||
"cannotRemoveOwnAdmin": "You cannot remove your own admin privileges",
|
||||
"cannotModifyOwnAdminStatus": "You cannot modify your own admin status",
|
||||
"dualAuth": "Dual Auth",
|
||||
"externalOIDC": "External (OIDC)",
|
||||
"localPassword": "Local Password",
|
||||
"confirmRevokeOwnSessions": "Are you sure you want to revoke all your own sessions? You will be logged out.",
|
||||
"confirmMakeAdmin": "Are you sure you want to make {{username}} an admin?",
|
||||
"confirmRemoveAdmin": "Are you sure you want to remove admin status from {{username}}?",
|
||||
"linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
|
||||
"linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
|
||||
"linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",
|
||||
@@ -747,33 +625,7 @@
|
||||
"passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.",
|
||||
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
|
||||
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?",
|
||||
"failedToUpdatePasswordLoginStatus": "Failed to update password login status",
|
||||
"sessionManagement": "Session Management",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"noActiveSessions": "No active sessions found.",
|
||||
"device": "Device",
|
||||
"user": "User",
|
||||
"created": "Created",
|
||||
"lastActive": "Last Active",
|
||||
"expires": "Expires",
|
||||
"revoked": "Revoked",
|
||||
"revokeAllUserSessionsTitle": "Revoke all sessions for this user",
|
||||
"revokeAll": "Revoke All",
|
||||
"linkOidcToPasswordAccount": "Link OIDC Account to Password Account",
|
||||
"linkOidcToPasswordAccountDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
|
||||
"linkOidcWarningTitle": "Warning: OIDC User Data Will Be Deleted",
|
||||
"linkOidcWarningDescription": "This action will:",
|
||||
"linkOidcActionDeleteUser": "Delete the OIDC user account and all their data",
|
||||
"linkOidcActionAddCapability": "Add OIDC login capability to the target password account",
|
||||
"linkOidcActionDualAuth": "Allow the password account to login with both password and OIDC",
|
||||
"linkTargetUsernameLabel": "Target Password Account Username",
|
||||
"linkTargetUsernamePlaceholder": "Enter username of password account",
|
||||
"linkingAccounts": "Linking...",
|
||||
"linkAccountsButton": "Link Accounts",
|
||||
"passwordMinLength": "Password must be at least 6 characters",
|
||||
"currentRoles": "Current Roles",
|
||||
"noRolesAssigned": "No roles assigned",
|
||||
"assignNewRole": "Assign New Role"
|
||||
"failedToUpdatePasswordLoginStatus": "Failed to update password login status"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Host Manager",
|
||||
@@ -784,7 +636,6 @@
|
||||
"failedToLoadHosts": "Failed to load hosts",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"optional": "Optional",
|
||||
"hostsCount": "{{count}} hosts",
|
||||
"importJson": "Import JSON",
|
||||
"importing": "Importing...",
|
||||
@@ -814,8 +665,6 @@
|
||||
"folder": "Folder",
|
||||
"tags": "Tags",
|
||||
"pin": "Pin",
|
||||
"notes": "Notes",
|
||||
"expirationDate": "Expiration Date",
|
||||
"passwordRequired": "Password is required when using password authentication",
|
||||
"sshKeyRequired": "SSH Private Key is required when using key authentication",
|
||||
"keyTypeRequired": "Key Type is required when using key authentication",
|
||||
@@ -828,17 +677,12 @@
|
||||
"hostAddedSuccessfully": "Host \"{{name}}\" added successfully!",
|
||||
"hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!",
|
||||
"failedToSaveHost": "Failed to save host. Please try again.",
|
||||
"savingHost": "Saving host...",
|
||||
"updatingHost": "Updating host...",
|
||||
"cloningHost": "Cloning host...",
|
||||
"enableTerminal": "Enable Terminal",
|
||||
"enableTerminalDesc": "Enable/disable host visibility in Terminal tab",
|
||||
"enableTunnel": "Enable Tunnel",
|
||||
"enableTunnelDesc": "Enable/disable host visibility in Tunnel tab",
|
||||
"enableFileManager": "Enable File Manager",
|
||||
"enableFileManagerDesc": "Enable/disable host visibility in File Manager tab",
|
||||
"enableDockerDesc": "Enable/disable host visibility in Docker tab",
|
||||
"enableDocker": "Enable Docker",
|
||||
"defaultPath": "Default Path",
|
||||
"defaultPathDesc": "Default directory when opening file manager for this host",
|
||||
"tunnelConnections": "Tunnel Connections",
|
||||
@@ -880,7 +724,6 @@
|
||||
"selectCredentialPlaceholder": "Choose a credential...",
|
||||
"credentialRequired": "Credential is required when using credential authentication",
|
||||
"credentialDescription": "Selecting a credential will overwrite the current username and use the credential's authentication details.",
|
||||
"cannotChangeAuthAsSharedUser": "Cannot change authentication as shared user",
|
||||
"sshPrivateKey": "SSH Private Key",
|
||||
"keyPassword": "Key Password",
|
||||
"keyType": "Key Type",
|
||||
@@ -942,25 +785,8 @@
|
||||
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
|
||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||
"failedToMoveToFolder": "Failed to move host to folder",
|
||||
"clickToRenameFolder": "Click to rename folder",
|
||||
"renameFolder": "Rename folder",
|
||||
"removeFromFolder": "Remove from folder \"{{folder}}\"",
|
||||
"editHostTooltip": "Edit host",
|
||||
"deleteHostTooltip": "Delete host",
|
||||
"exportHostTooltip": "Export host",
|
||||
"cloneHostTooltip": "Clone host",
|
||||
"clickToEditHost": "Click to edit host",
|
||||
"dragToMoveBetweenFolders": "Drag to move between folders",
|
||||
"exportedHostConfig": "Exported host configuration for {{name}}",
|
||||
"openTerminal": "Open Terminal",
|
||||
"openFileManager": "Open File Manager",
|
||||
"openTunnels": "Open Tunnels",
|
||||
"openServerDetails": "Open Server Details",
|
||||
"statistics": "Statistics",
|
||||
"enabledWidgets": "Enabled Widgets",
|
||||
"openServerStats": "Open Server Stats",
|
||||
"openFileManager": "Open File Manager",
|
||||
"openTunnels": "Open Tunnels",
|
||||
"enabledWidgetsDesc": "Select which statistics widgets to display for this host",
|
||||
"monitoringConfiguration": "Monitoring Configuration",
|
||||
"monitoringConfigurationDesc": "Configure how often server statistics and status are checked",
|
||||
@@ -980,6 +806,7 @@
|
||||
"monitoringDisabledBadge": "Monitoring Off",
|
||||
"statusMonitoring": "Status",
|
||||
"metricsMonitoring": "Metrics",
|
||||
"terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.",
|
||||
"terminalCustomization": "Terminal Customization",
|
||||
"appearance": "Appearance",
|
||||
"behavior": "Behavior",
|
||||
@@ -1047,7 +874,7 @@
|
||||
"noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.",
|
||||
"noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.",
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is sometimes required for servers that use Two-Factor Authentication (TOTP/2FA).",
|
||||
"forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.",
|
||||
"jumpHosts": "Jump Hosts",
|
||||
@@ -1058,47 +885,6 @@
|
||||
"searchServers": "Search servers...",
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"socks5Proxy": "SOCKS5 Proxy",
|
||||
"socks5Description": "Configure SOCKS5 proxy for SSH connection. All traffic will be routed through the specified proxy server.",
|
||||
"enableSocks5": "Enable SOCKS5 Proxy",
|
||||
"enableSocks5Description": "Use SOCKS5 proxy for this SSH connection",
|
||||
"socks5Host": "Proxy Host",
|
||||
"socks5Port": "Proxy Port",
|
||||
"socks5Username": "Proxy Username",
|
||||
"socks5Password": "Proxy Password",
|
||||
"socks5UsernameOptional": "Optional: leave empty if proxy doesn't require authentication",
|
||||
"socks5PasswordOptional": "Optional: leave empty if proxy doesn't require authentication",
|
||||
"socks5ProxyChain": "Proxy Chain",
|
||||
"socks5ProxyChainDescription": "Configure a chain of SOCKS proxies. Each proxy in the chain will connect through the previous one.",
|
||||
"socks5ProxyMode": "Proxy Mode",
|
||||
"socks5UseSingleProxy": "Use Single Proxy",
|
||||
"socks5UseProxyChain": "Use Proxy Chain",
|
||||
"socks5UsePreset": "Use Saved Preset",
|
||||
"socks5SelectPreset": "Select Preset",
|
||||
"socks5ManagePresets": "Manage Presets",
|
||||
"socks5ProxyNode": "Proxy {{number}}",
|
||||
"socks5AddProxy": "Add Proxy to Chain",
|
||||
"socks5RemoveProxy": "Remove Proxy",
|
||||
"socks5ProxyType": "Proxy Type",
|
||||
"socks5SaveAsPreset": "Save as Preset",
|
||||
"socks5SavePresetTitle": "Save Proxy Chain as Preset",
|
||||
"socks5SavePresetDescription": "Save the current proxy chain configuration as a reusable preset",
|
||||
"socks5PresetName": "Preset Name",
|
||||
"socks5PresetDescription": "Description (optional)",
|
||||
"socks5PresetCreated": "Proxy chain preset created",
|
||||
"socks5PresetUpdated": "Proxy chain preset updated",
|
||||
"socks5PresetDeleted": "Proxy chain preset deleted",
|
||||
"socks5PresetSaved": "Preset \"{{name}}\" saved successfully",
|
||||
"socks5PresetSaveError": "Failed to save preset",
|
||||
"socks5PresetNameRequired": "Preset name is required",
|
||||
"socks5EmptyChainError": "Cannot save an empty proxy chain",
|
||||
"socks5ProxyChainEmpty": "Add at least one proxy to the chain",
|
||||
"socks5HostDescription": "Hostname or IP address of the SOCKS proxy server",
|
||||
"socks5PortDescription": "Port number of the SOCKS proxy server (default: 1080)",
|
||||
"addProxyNode": "Add Proxy Node",
|
||||
"noProxyNodes": "No proxy nodes configured. Click 'Add Proxy Node' to add one.",
|
||||
"proxyNode": "Proxy Node",
|
||||
"proxyType": "Proxy Type",
|
||||
"quickActions": "Quick Actions",
|
||||
"quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.",
|
||||
"quickActionsList": "Quick Actions List",
|
||||
@@ -1108,134 +894,7 @@
|
||||
"quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page",
|
||||
"advancedAuthSettings": "Advanced Authentication Settings",
|
||||
"sudoPasswordAutoFill": "Sudo Password Auto-Fill",
|
||||
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password",
|
||||
"sudoPassword": "Sudo Password",
|
||||
"sudoPasswordDesc": "Optional password for sudo commands (useful with key authentication)",
|
||||
"socks4": "SOCKS4",
|
||||
"socks5": "SOCKS5",
|
||||
"executeSnippetOnConnect": "Execute a snippet when the terminal connects",
|
||||
"autoMosh": "Auto-MOSH",
|
||||
"autoMoshDesc": "Automatically run MOSH command on connect",
|
||||
"moshCommand": "MOSH Command",
|
||||
"moshCommandDesc": "The MOSH command to execute",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"environmentVariablesDesc": "Set custom environment variables for the terminal session",
|
||||
"variableName": "Variable name",
|
||||
"variableValue": "Value",
|
||||
"addVariable": "Add Variable",
|
||||
"docker": "Docker",
|
||||
"openDocker": "Open Docker",
|
||||
"notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.",
|
||||
"validating": "Validating Docker...",
|
||||
"error": "Error",
|
||||
"errorCode": "Error code: {{code}}",
|
||||
"version": "Docker v{{version}}",
|
||||
"current": "Current",
|
||||
"used_limit": "Used / Limit",
|
||||
"percentage": "Percentage",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"console": "Console",
|
||||
"containerMustBeRunning": "Container must be running to connect to console",
|
||||
"authenticationRequired": "Authentication required",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to console",
|
||||
"disconnectedFromContainer": "Disconnected from container console.",
|
||||
"containerNotRunning": "Container is not running",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "Sh",
|
||||
"ash": "Ash",
|
||||
"connecting": "Connecting...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click Connect to start an interactive shell",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerMustBeRunningToViewStats": "Container must be running to view stats",
|
||||
"failedToFetchStats": "Failed to fetch stats",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "Start by creating containers on your server",
|
||||
"searchPlaceholder": "Search by name, image, or ID...",
|
||||
"filterByStatusPlaceholder": "Filter by status",
|
||||
"allContainersCount": "All ({{count}})",
|
||||
"statusCount": "{{status}} ({{count}})",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container: {{error}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container: {{error}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container: {{error}}",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"failedToTogglePauseContainer": "Failed to {{action}} container: {{error}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container: {{error}}",
|
||||
"image": "Image:",
|
||||
"idLabel": "ID:",
|
||||
"ports": "Ports:",
|
||||
"noPorts": "None",
|
||||
"created": "Created:",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"unpause": "Unpause",
|
||||
"pause": "Pause",
|
||||
"restart": "Restart",
|
||||
"remove": "Remove",
|
||||
"removeContainer": "Remove Container",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove container \"{{name}}\"?",
|
||||
"runningContainerWarning": "Warning: This container is currently running and will be force-removed.",
|
||||
"removing": "Removing:",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to list",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}",
|
||||
"linesToShow": "Lines to show",
|
||||
"last50Lines": "Last 50 lines",
|
||||
"last100Lines": "Last 100 lines",
|
||||
"last500Lines": "Last 500 lines",
|
||||
"last1000Lines": "Last 1000 lines",
|
||||
"allLogs": "All logs",
|
||||
"showTimestamps": "Show Timestamps",
|
||||
"autoRefresh": "Auto Refresh",
|
||||
"filterLogsPlaceholder": "Filter logs...",
|
||||
"noLogsAvailable": "No logs available"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Split Screen",
|
||||
"none": "None",
|
||||
"twoSplit": "2-Split",
|
||||
"threeSplit": "3-Split",
|
||||
"fourSplit": "4-Split",
|
||||
"availableTabs": "Available Tabs",
|
||||
"dragTabsHint": "Drag tabs into the grid below to position them",
|
||||
"layout": "Layout",
|
||||
"dropHere": "Drop tab here",
|
||||
"apply": "Apply Split",
|
||||
"clear": "Clear",
|
||||
"selectMode": "Select a split mode to get started",
|
||||
"helpText": "Choose how many tabs you want to display at once",
|
||||
"error": {
|
||||
"noAssignments": "Please drag tabs to cells before applying",
|
||||
"fillAllSlots": "Please fill all {{count}} layout spots before applying"
|
||||
},
|
||||
"success": "Split screen applied",
|
||||
"cleared": "Split screen cleared"
|
||||
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
@@ -1421,7 +1080,7 @@
|
||||
"connectToServer": "Connect to a Server",
|
||||
"selectServerToEdit": "Select a server from the sidebar to start editing files",
|
||||
"fileOperations": "File Operations",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete {{name}}?",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?",
|
||||
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
|
||||
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
|
||||
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",
|
||||
@@ -1566,23 +1225,6 @@
|
||||
"loadFileFailed": "Failed to load file: {{error}}",
|
||||
"connectedSuccessfully": "Connected successfully",
|
||||
"totpVerificationFailed": "TOTP verification failed",
|
||||
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
|
||||
"verificationCodePrompt": "Verification code:",
|
||||
"newFolderDefault": "NewFolder",
|
||||
"newFileDefault": "NewFile.txt",
|
||||
"successfullyMovedItems": "Successfully moved {{count}} items to {{target}}",
|
||||
"move": "Move",
|
||||
"searchInFile": "Search in file (Ctrl+F)",
|
||||
"showKeyboardShortcuts": "Show keyboard shortcuts",
|
||||
"startWritingMarkdown": "Start writing your markdown content...",
|
||||
"loadingFileComparison": "Loading file comparison...",
|
||||
"reload": "Reload",
|
||||
"compare": "Compare",
|
||||
"sideBySide": "Side by Side",
|
||||
"inline": "Inline",
|
||||
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
|
||||
"fileTooLarge": "File too large: {{error}}",
|
||||
"connectedSuccessfully": "Connected successfully",
|
||||
"changePermissions": "Change Permissions",
|
||||
"changePermissionsDesc": "Modify file permissions for",
|
||||
"currentPermissions": "Current Permissions",
|
||||
@@ -1596,10 +1238,6 @@
|
||||
"permissionsChangedSuccessfully": "Permissions changed successfully",
|
||||
"failedToChangePermissions": "Failed to change permissions"
|
||||
},
|
||||
"tunnel": {
|
||||
"noTunnelsConfigured": "No Tunnels Configured",
|
||||
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH Tunnels",
|
||||
"noSshTunnels": "No SSH Tunnels",
|
||||
@@ -1609,7 +1247,6 @@
|
||||
"connecting": "Connecting...",
|
||||
"disconnecting": "Disconnecting...",
|
||||
"unknownTunnelStatus": "Unknown",
|
||||
"statusUnknown": "Unknown",
|
||||
"unknown": "Unknown",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
@@ -1625,7 +1262,6 @@
|
||||
"attempt": "Attempt {{current}} of {{max}}",
|
||||
"nextRetryIn": "Next retry in {{seconds}} seconds",
|
||||
"checkDockerLogs": "Check your Docker logs for the error reason, join the",
|
||||
"orCreate": "or create a ",
|
||||
"noTunnelConnections": "No tunnel connections configured",
|
||||
"tunnelConnections": "Tunnel Connections",
|
||||
"addTunnel": "Add Tunnel",
|
||||
@@ -1688,18 +1324,11 @@
|
||||
"failedToFetchMetrics": "Failed to fetch server metrics",
|
||||
"failedToFetchHomeData": "Failed to fetch home data",
|
||||
"loadingMetrics": "Loading metrics...",
|
||||
"connecting": "Connecting...",
|
||||
"refreshing": "Refreshing...",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpRequired": "TOTP Authentication Required",
|
||||
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
||||
"totpVerified": "TOTP verified, metrics collection started",
|
||||
"totpFailed": "TOTP verification failed",
|
||||
"totpInvalidCode": "Invalid verification code",
|
||||
"totpCancelled": "Metrics collection cancelled",
|
||||
"authenticationFailed": "Authentication failed",
|
||||
"noneAuthNotSupported": "Server Stats does not support 'none' authentication type.",
|
||||
"load": "Load",
|
||||
"editLayout": "Edit Layout",
|
||||
"cancelEdit": "Cancel",
|
||||
@@ -1921,25 +1550,9 @@
|
||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
||||
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
||||
"terminalSyntaxHighlighting": "Terminal Syntax Highlighting",
|
||||
"showHostTags": "Show Host Tags",
|
||||
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
|
||||
"account": "Account",
|
||||
"appearance": "Appearance",
|
||||
"languageLocalization": "Language & Localization",
|
||||
"fileManagerSettings": "File Manager",
|
||||
"terminalSettings": "Terminal",
|
||||
"hostSidebarSettings": "Host & Sidebar",
|
||||
"snippetsSettings": "Snippets",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again.",
|
||||
"theme": "Theme",
|
||||
"themeLight": "Light",
|
||||
"themeDark": "Dark",
|
||||
"themeSystem": "System",
|
||||
"appearanceDesc": "Select the color theme for the application",
|
||||
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output"
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Failed to load version information"
|
||||
@@ -1956,9 +1569,6 @@
|
||||
"folder": "folder",
|
||||
"password": "password",
|
||||
"keyPassword": "key password",
|
||||
"sudoPassword": "sudo password (optional)",
|
||||
"notes": "add notes about this host...",
|
||||
"expirationDate": "Select expiration date",
|
||||
"pastePrivateKey": "Paste your private key here...",
|
||||
"pastePublicKey": "Paste your public key here...",
|
||||
"credentialName": "My SSH Server",
|
||||
@@ -1979,24 +1589,14 @@
|
||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||
"enterPassword": "Enter your password",
|
||||
"totpCode": "6-digit TOTP code",
|
||||
"searchHostsAny": "Search hosts (try: tag:prod, user:root, ip:192.168)...",
|
||||
"searchHostsAny": "Search hosts by any info...",
|
||||
"confirmPassword": "Enter your password to confirm",
|
||||
"typeHere": "Type here",
|
||||
"fileName": "Enter file name (e.g., example.txt)",
|
||||
"folderName": "Enter folder name",
|
||||
"fullPath": "Enter full path to item",
|
||||
"currentPath": "Enter current path to item",
|
||||
"newName": "Enter new name",
|
||||
"socks5Host": "127.0.0.1",
|
||||
"socks5Username": "proxy username",
|
||||
"socks5Password": "proxy password",
|
||||
"socks5PresetName": "e.g., Work VPN Chain",
|
||||
"socks5PresetDescription": "e.g., Proxy chain for accessing work servers",
|
||||
"moshCommand": "mosh user@server",
|
||||
"defaultPort": "22",
|
||||
"defaultEndpointPort": "224",
|
||||
"defaultMaxRetries": "3",
|
||||
"defaultRetryInterval": "10"
|
||||
"newName": "Enter new name"
|
||||
},
|
||||
"leftSidebar": {
|
||||
"failedToLoadHosts": "Failed to load hosts",
|
||||
@@ -2165,169 +1765,6 @@
|
||||
"ram": "RAM",
|
||||
"notAvailable": "N/A"
|
||||
},
|
||||
"rbac": {
|
||||
"shareHost": "Share Host",
|
||||
"shareHostTitle": "Share Host Access",
|
||||
"shareHostDescription": "Grant temporary or permanent access to this host",
|
||||
"targetUser": "Target User",
|
||||
"selectUser": "Select a user to share with",
|
||||
"duration": "Duration",
|
||||
"durationHours": "Duration (hours)",
|
||||
"neverExpires": "Never expires",
|
||||
"permissionLevel": "Permission Level",
|
||||
"permissionLevels": {
|
||||
"readonly": "Read-Only",
|
||||
"readonlyDesc": "Can view only, no command input",
|
||||
"restricted": "Restricted",
|
||||
"restrictedDesc": "Blocks dangerous commands (passwd, rm -rf, etc.)",
|
||||
"monitored": "Monitored",
|
||||
"monitoredDesc": "Records all commands but doesn't block (Recommended)",
|
||||
"full": "Full Access",
|
||||
"fullDesc": "No restrictions (Not recommended)"
|
||||
},
|
||||
"blockedCommands": "Blocked Commands",
|
||||
"blockedCommandsPlaceholder": "Enter commands to block, e.g., passwd, rm, dd",
|
||||
"maxSessionDuration": "Max Session Duration (minutes)",
|
||||
"createTempUser": "Create Temporary User",
|
||||
"createTempUserDesc": "Creates a restricted user on the server instead of sharing your credentials. Requires sudo access. Most secure option.",
|
||||
"expiresAt": "Expires At",
|
||||
"expiresIn": "Expires in {{hours}} hours",
|
||||
"expired": "Expired",
|
||||
"grantedBy": "Granted By",
|
||||
"accessLevel": "Access Level",
|
||||
"lastAccessed": "Last Accessed",
|
||||
"accessCount": "Access Count",
|
||||
"revokeAccess": "Revoke Access",
|
||||
"confirmRevokeAccess": "Are you sure you want to revoke access for {{username}}?",
|
||||
"hostSharedSuccessfully": "Host shared successfully with {{username}}",
|
||||
"hostAccessUpdated": "Host access updated",
|
||||
"failedToShareHost": "Failed to share host",
|
||||
"accessRevokedSuccessfully": "Access revoked successfully",
|
||||
"failedToRevokeAccess": "Failed to revoke access",
|
||||
"shared": "Shared",
|
||||
"sharedHosts": "Shared Hosts",
|
||||
"sharedWithMe": "Shared With Me",
|
||||
"noSharedHosts": "No hosts shared with you",
|
||||
"owner": "Owner",
|
||||
"viewAccessList": "View Access List",
|
||||
"accessList": "Access List",
|
||||
"noAccessGranted": "No access has been granted for this host",
|
||||
"noAccessGrantedMessage": "No users have been granted access to this host yet",
|
||||
"manageAccessFor": "Manage access for",
|
||||
"totalAccessRecords": "{{count}} access record(s)",
|
||||
"neverAccessed": "Never",
|
||||
"timesAccessed": "{{count}} time(s)",
|
||||
"daysRemaining": "{{days}} day(s)",
|
||||
"hoursRemaining": "{{hours}} hour(s)",
|
||||
"expired": "Expired",
|
||||
"failedToFetchAccessList": "Failed to fetch access list",
|
||||
"currentAccess": "Current Access",
|
||||
"securityWarning": "Security Warning",
|
||||
"securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.",
|
||||
"tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.",
|
||||
"roleManagement": "Role Management",
|
||||
"manageRoles": "Manage Roles",
|
||||
"manageRolesFor": "Manage roles for {{username}}",
|
||||
"assignRole": "Assign Role",
|
||||
"removeRole": "Remove Role",
|
||||
"userRoles": "User Roles",
|
||||
"permissions": "Permissions",
|
||||
"systemRole": "System Role",
|
||||
"customRole": "Custom Role",
|
||||
"roleAssignedSuccessfully": "Role assigned to {{username}} successfully",
|
||||
"failedToAssignRole": "Failed to assign role",
|
||||
"roleRemovedSuccessfully": "Role removed from {{username}} successfully",
|
||||
"failedToRemoveRole": "Failed to remove role",
|
||||
"cannotRemoveSystemRole": "Cannot remove system role",
|
||||
"cannotShareWithSelf": "Cannot share host with yourself",
|
||||
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
|
||||
"credentialSharingWarning": "Credential Authentication Not Supported for Sharing",
|
||||
"credentialRequired": "Credential is required when sharing a host",
|
||||
"credentialRequiredDescription": "This host does not use credential-based authentication. In order to share hosts, due to per-user-encryption, the host must use credential based authentication.",
|
||||
"auditLogs": "Audit Logs",
|
||||
"viewAuditLogs": "View Audit Logs",
|
||||
"action": "Action",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceName": "Resource Name",
|
||||
"timestamp": "Timestamp",
|
||||
"ipAddress": "IP Address",
|
||||
"userAgent": "User Agent",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"details": "Details",
|
||||
"noAuditLogs": "No audit logs available",
|
||||
"sessionRecordings": "Session Recordings",
|
||||
"viewRecording": "View Recording",
|
||||
"downloadRecording": "Download Recording",
|
||||
"dangerousCommand": "Dangerous Command Detected",
|
||||
"commandBlocked": "Command Blocked",
|
||||
"terminateSession": "Terminate Session",
|
||||
"sessionTerminated": "Session terminated by host owner",
|
||||
"sharedAccessExpired": "Your shared access to this host has expired",
|
||||
"sharedAccessExpiresIn": "Shared access expires in {{hours}} hours",
|
||||
"roles": {
|
||||
"label": "Roles",
|
||||
"admin": "Administrator",
|
||||
"user": "User"
|
||||
},
|
||||
"createRole": "Create Role",
|
||||
"editRole": "Edit Role",
|
||||
"roleName": "Role Name",
|
||||
"displayName": "Display Name",
|
||||
"description": "Description",
|
||||
"assignRoles": "Assign Roles",
|
||||
"userRoleAssignment": "User-Role Assignment",
|
||||
"selectUserPlaceholder": "Select a user",
|
||||
"searchUsers": "Search users...",
|
||||
"noUserFound": "No user found",
|
||||
"currentRoles": "Current Roles",
|
||||
"noRolesAssigned": "No roles assigned",
|
||||
"assignNewRole": "Assign New Role",
|
||||
"selectRolePlaceholder": "Select a role",
|
||||
"searchRoles": "Search roles...",
|
||||
"noRoleFound": "No role found",
|
||||
"assign": "Assign",
|
||||
"roleCreatedSuccessfully": "Role created successfully",
|
||||
"roleUpdatedSuccessfully": "Role updated successfully",
|
||||
"roleDeletedSuccessfully": "Role deleted successfully",
|
||||
"failedToLoadRoles": "Failed to load roles",
|
||||
"failedToSaveRole": "Failed to save role",
|
||||
"failedToDeleteRole": "Failed to delete role",
|
||||
"roleDisplayNameRequired": "Role display name is required",
|
||||
"roleNameRequired": "Role name is required",
|
||||
"roleNameHint": "Use lowercase letters, numbers, underscores, and hyphens only",
|
||||
"displayNamePlaceholder": "Developer",
|
||||
"descriptionPlaceholder": "Software developers and engineers",
|
||||
"confirmDeleteRole": "Delete Role",
|
||||
"confirmDeleteRoleDescription": "Are you sure you want to delete the role '{{name}}'? This action cannot be undone.",
|
||||
"confirmRemoveRole": "Remove Role",
|
||||
"confirmRemoveRoleDescription": "Are you sure you want to remove this role from the user?",
|
||||
"editRoleDescription": "Update role information",
|
||||
"createRoleDescription": "Create a new custom role for grouping users",
|
||||
"assignRolesDescription": "Manage role assignments for users",
|
||||
"noRoles": "No roles found",
|
||||
"selectRole": "Select Role",
|
||||
"type": "Type",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"saveHostFirst": "Save Host First",
|
||||
"saveHostFirstDescription": "Please save the host before configuring sharing settings.",
|
||||
"shareWithUser": "Share with User",
|
||||
"shareWithRole": "Share with Role",
|
||||
"share": "Share",
|
||||
"target": "Target",
|
||||
"expires": "Expires",
|
||||
"never": "Never",
|
||||
"noAccessRecords": "No access records found",
|
||||
"sharedSuccessfully": "Shared successfully",
|
||||
"failedToShare": "Failed to share",
|
||||
"confirmRevokeAccessDescription": "Are you sure you want to revoke this access?",
|
||||
"hours": "hours",
|
||||
"sharing": "Sharing",
|
||||
"selectUserAndRole": "Please select both a user and a role",
|
||||
"view": "View Only",
|
||||
"viewDesc": "Due to the Termix encryption system, other permission levels will come at a later date"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search for hosts or quick actions...",
|
||||
"recentActivity": "Recent Activity",
|
||||
@@ -2349,104 +1786,6 @@
|
||||
"press": "Press",
|
||||
"toToggle": "to toggle",
|
||||
"close": "Close",
|
||||
"hostManager": "Host Manager",
|
||||
"pressToToggle": "Press Left Shift twice to open the command palette"
|
||||
},
|
||||
"docker": {
|
||||
"notEnabled": "Docker is not enabled for this host",
|
||||
"validating": "Validating Docker...",
|
||||
"connectingToHost": "Connecting to host...",
|
||||
"error": "Error",
|
||||
"errorCode": "Error code: {{code}}",
|
||||
"version": "Docker {{version}}",
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"failedToStartContainer": "Failed to start container {{name}}",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"failedToStopContainer": "Failed to stop container {{name}}",
|
||||
"containerRestarted": "Container {{name}} restarted",
|
||||
"failedToRestartContainer": "Failed to restart container {{name}}",
|
||||
"containerPaused": "Container {{name}} paused",
|
||||
"containerUnpaused": "Container {{name}} unpaused",
|
||||
"failedToTogglePauseContainer": "Failed to toggle pause state for container {{name}}",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"failedToRemoveContainer": "Failed to remove container {{name}}",
|
||||
"image": "Image",
|
||||
"idLabel": "ID",
|
||||
"ports": "Ports",
|
||||
"noPorts": "No ports",
|
||||
"created": "Created",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"pause": "Pause",
|
||||
"unpause": "Unpause",
|
||||
"restart": "Restart",
|
||||
"remove": "Remove",
|
||||
"removeContainer": "Remove Container",
|
||||
"confirmRemoveContainer": "Are you sure you want to remove the container '{{name}}'? This action cannot be undone.",
|
||||
"runningContainerWarning": "Warning: This container is currently running. Removing it will stop the container first.",
|
||||
"removing": "Removing...",
|
||||
"loadingContainers": "Loading containers...",
|
||||
"noContainersFound": "No containers found",
|
||||
"noContainersFoundHint": "No Docker containers are available on this host",
|
||||
"searchPlaceholder": "Search containers...",
|
||||
"filterByStatusPlaceholder": "Filter by status",
|
||||
"allContainersCount": "All ({{count}})",
|
||||
"statusCount": "{{status}} ({{count}})",
|
||||
"noContainersMatchFilters": "No containers match your filters",
|
||||
"noContainersMatchFiltersHint": "Try adjusting your search or filter criteria",
|
||||
"containerMustBeRunningToViewStats": "Container must be running to view statistics",
|
||||
"failedToFetchStats": "Failed to fetch container statistics",
|
||||
"containerNotRunning": "Container not running",
|
||||
"startContainerToViewStats": "Start the container to view statistics",
|
||||
"loadingStats": "Loading statistics...",
|
||||
"errorLoadingStats": "Error loading statistics",
|
||||
"noStatsAvailable": "No statistics available",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"current": "Current",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"usedLimit": "Used / Limit",
|
||||
"percentage": "Percentage",
|
||||
"networkIo": "Network I/O",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"blockIo": "Block I/O",
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"pids": "PIDs",
|
||||
"containerInformation": "Container Information",
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"state": "State",
|
||||
"disconnectedFromContainer": "Disconnected from container",
|
||||
"containerMustBeRunning": "Container must be running to access console",
|
||||
"authenticationRequired": "Authentication required",
|
||||
"verificationCodePrompt": "Enter verification code",
|
||||
"totpVerificationFailed": "TOTP verification failed. Please try again.",
|
||||
"connectedTo": "Connected to {{containerName}}",
|
||||
"disconnected": "Disconnected",
|
||||
"consoleError": "Console error",
|
||||
"errorMessage": "Error: {{message}}",
|
||||
"failedToConnect": "Failed to connect to container",
|
||||
"console": "Console",
|
||||
"selectShell": "Select shell",
|
||||
"bash": "Bash",
|
||||
"sh": "sh",
|
||||
"ash": "ash",
|
||||
"connecting": "Connecting...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"notConnected": "Not connected",
|
||||
"clickToConnect": "Click connect to start a shell session",
|
||||
"connectingTo": "Connecting to {{containerName}}...",
|
||||
"containerNotFound": "Container not found",
|
||||
"backToList": "Back to List",
|
||||
"logs": "Logs",
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console"
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Switch to Light",
|
||||
"switchToDark": "Switch to Dark"
|
||||
"hostManager": "Host Manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
2402
src/locales/es.json
2402
src/locales/es.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/fr.json
2402
src/locales/fr.json
File diff suppressed because it is too large
Load Diff
1789
src/locales/fr/translation.json
Normal file
1789
src/locales/fr/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/he.json
2402
src/locales/he.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/hi.json
2402
src/locales/hi.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/id.json
2402
src/locales/id.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/it.json
2402
src/locales/it.json
File diff suppressed because it is too large
Load Diff
3470
src/locales/it/translation.json
Normal file
3470
src/locales/it/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/ja.json
2402
src/locales/ja.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ko.json
2402
src/locales/ko.json
File diff suppressed because it is too large
Load Diff
1815
src/locales/ko/translation.json
Normal file
1815
src/locales/ko/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/nb.json
2402
src/locales/nb.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/nl.json
2402
src/locales/nl.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/pl.json
2402
src/locales/pl.json
File diff suppressed because it is too large
Load Diff
1785
src/locales/pt-BR/translation.json
Normal file
1785
src/locales/pt-BR/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/pt.json
2402
src/locales/pt.json
File diff suppressed because it is too large
Load Diff
2402
src/locales/ro.json
2402
src/locales/ro.json
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user