mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
release-2.4.1 (#921)
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
@@ -734,8 +734,6 @@ jobs:
|
|||||||
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
|
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
|
||||||
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
|
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
|
||||||
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
|
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
|
||||||
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
|
|
||||||
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
|
|
||||||
|
|
||||||
mkdir -p flatpak-submission
|
mkdir -p flatpak-submission
|
||||||
|
|
||||||
@@ -762,6 +760,58 @@ jobs:
|
|||||||
path: flatpak-submission/*
|
path: flatpak-submission/*
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Check for Flathub token
|
||||||
|
id: check_flathub_token
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.FLATHUB_TOKEN }}" ]; then
|
||||||
|
echo "has_token=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Open PR on Flathub repo
|
||||||
|
if: steps.check_flathub_token.outputs.has_token == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.package-version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
FLATHUB_REPO="flathub/com.karmaa.termix"
|
||||||
|
BRANCH="release-$VERSION"
|
||||||
|
|
||||||
|
git config --global user.name "LukeGus"
|
||||||
|
git config --global user.email "bugattiguy527@gmail.com"
|
||||||
|
|
||||||
|
git clone "https://x-access-token:${GH_TOKEN}@github.com/${FLATHUB_REPO}.git" flathub-repo
|
||||||
|
cd flathub-repo
|
||||||
|
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
|
||||||
|
cp ../flatpak-submission/com.karmaa.termix.yml ./
|
||||||
|
cp ../flatpak-submission/com.karmaa.termix.desktop ./
|
||||||
|
cp ../flatpak-submission/com.karmaa.termix.metainfo.xml ./
|
||||||
|
cp ../flatpak-submission/flathub.json ./
|
||||||
|
cp ../flatpak-submission/com.karmaa.termix.svg ./
|
||||||
|
cp ../flatpak-submission/icon-256.png ./
|
||||||
|
cp ../flatpak-submission/icon-128.png ./
|
||||||
|
|
||||||
|
if git diff --quiet && git diff --cached --quiet; then
|
||||||
|
echo "No changes to submit for $VERSION; Flathub repo already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "Update to $VERSION"
|
||||||
|
git push origin "$BRANCH"
|
||||||
|
|
||||||
|
EXISTING_PR=$(gh pr list --repo "$FLATHUB_REPO" --head "$BRANCH" --state open --json number -q '.[0].number' || true)
|
||||||
|
if [ -z "$EXISTING_PR" ]; then
|
||||||
|
gh pr create --repo "$FLATHUB_REPO" \
|
||||||
|
--base master \
|
||||||
|
--head "$BRANCH" \
|
||||||
|
--title "Update to $VERSION" \
|
||||||
|
--body "Automated release update to version $VERSION."
|
||||||
|
else
|
||||||
|
echo "PR #$EXISTING_PR already open for $BRANCH."
|
||||||
|
fi
|
||||||
|
|
||||||
submit-to-homebrew:
|
submit-to-homebrew:
|
||||||
runs-on: blacksmith-6vcpu-macos-latest
|
runs-on: blacksmith-6vcpu-macos-latest
|
||||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||||
|
|||||||
@@ -85,14 +85,14 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout dev branch
|
- name: Checkout dev branch
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -137,17 +137,25 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout dev branch
|
- name: Checkout dev branch
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GHCR_TOKEN }}
|
token: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
|
- name: Merge main into dev branch
|
||||||
|
run: |
|
||||||
|
git config user.name "LukeGus"
|
||||||
|
git config user.email "bugattiguy527@gmail.com"
|
||||||
|
git fetch origin main
|
||||||
|
git merge origin/main -X ours --no-edit || true
|
||||||
|
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||||
|
|
||||||
- name: Clean up stale Crowdin git integration branch
|
- name: Clean up stale Crowdin git integration branch
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
@@ -287,13 +295,13 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
@@ -399,14 +407,14 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Termix
|
- name: Checkout Termix
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
path: termix
|
path: termix
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: "termix/.nvmrc"
|
node-version-file: "termix/.nvmrc"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
@@ -419,7 +427,7 @@ jobs:
|
|||||||
npm run generate:openapi
|
npm run generate:openapi
|
||||||
|
|
||||||
- name: Checkout Docs repository
|
- name: Checkout Docs repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
repository: Termix-SSH/Docs
|
repository: Termix-SSH/Docs
|
||||||
ref: main
|
ref: main
|
||||||
@@ -488,13 +496,13 @@ jobs:
|
|||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ electron/build-info.cjs
|
|||||||
/.mcp.json
|
/.mcp.json
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/old_db/
|
/old_db/
|
||||||
|
/scripts/fix-bugs.mjs
|
||||||
|
/scripts/fix-features.mjs
|
||||||
|
|||||||
+105
-35
@@ -1,6 +1,6 @@
|
|||||||
<!-- SUMMARY -->
|
<!-- SUMMARY -->
|
||||||
|
|
||||||
Bug fixes and new features, including Proxmox integration, SSO/OIDC redesign, revamped host metrics, tmux session management, and numerous UI and stability improvements.
|
Dozens of bug fixes and small new features, including VNC/RDP sharing, OIDC improvements, file manager fixes, SSH jump host fixes, terminal enhancements, RDP keyboard layout support, and much more.
|
||||||
|
|
||||||
<!-- /SUMMARY -->
|
<!-- /SUMMARY -->
|
||||||
|
|
||||||
@@ -12,42 +12,112 @@ https://youtu.be/ImwAbm4hW-k
|
|||||||
|
|
||||||
<!-- UPDATE_LOG -->
|
<!-- UPDATE_LOG -->
|
||||||
|
|
||||||
- Improved terminal syntax highlighting with more customizability and reliability (toggle setting moved from user profile to host editor)
|
- VNC, RDP, and Telnet credential sharing support
|
||||||
- Tmux session monitor/management
|
- Default host settings (SOCKS5, credentials, terminal settings, and more)
|
||||||
- Tailscale SSH authentication and device listing support
|
- Custom terminal background image per host
|
||||||
- SSO/OIDC redesign (multiple OIDC providers, LDAP, Google, GitHub support)
|
- Silent OIDC login configurable as default (no URL parameter required)
|
||||||
- Terminal session logging
|
- Bulk open SSH sessions for multiple selected hosts at once
|
||||||
- Customize side rail tab visibility
|
- Improved Nord theme contrast and accessibility
|
||||||
- Admin audit log
|
- Admin can manually create users while registration is disabled
|
||||||
- Added `x.x.x` version tag alongside `release-x.x.x` for Docker
|
- OIDC auto-provisioned users supported when registration and password login are disabled
|
||||||
- OIDC custom group claim support
|
- OIDC username usable as SSH username credential
|
||||||
- Renamed server stats to host metrics
|
- Custom labels and drag-to-reorder for sessions in the Connections panel
|
||||||
- Fully revamped host metrics page with new cards and dashboard like organizing system (services, process inspector, log viewer, cron jobs, packages, ssl cert management, firewall, user/permissions, health checks, disk breakdown, timers, and top by memory)
|
- Appearance and profile settings persisted to the database across devices
|
||||||
- Proxmox guest discovery and import integration
|
- Saved server URL dropdown in the app connection screen
|
||||||
- Moved ssh host config outside of top tab bar and into new tab bar visible on SSH tab
|
- File manager text editor font size adjustment
|
||||||
- Improved folder management (nested folders, folder icons, folder colors, better folder selection, etc.)
|
- Tab key shortcut for entering your username in the terminal
|
||||||
- Storage preference to user profile settings (store/load toggles locally or in the DB)
|
- Shift+Tab hotkey support for mobile
|
||||||
- Sort/filter functions to credential list (copy of host list)
|
- Terminal keyboard shortcuts support
|
||||||
|
- UTF-8 encoding support in the file manager
|
||||||
|
- Optional broadcast address for Wake-on-LAN packets
|
||||||
|
- SFTP legacy mode support
|
||||||
|
- Snippets JSON import and export with folder metadata
|
||||||
|
- Clickable links and service shortcuts on the dashboard
|
||||||
|
- Host status color indicators restored to sidebar
|
||||||
|
- Per-host configuration to set tab title to shell's window title instead of host name
|
||||||
|
- Split screen hotkeys
|
||||||
|
- Support for AZERTY and other non-QWERTY keyboard layouts in RDP
|
||||||
|
- RDP load balancing info and Connection Broker Cookie support
|
||||||
|
- Per-connection guacd proxy host and port configuration
|
||||||
|
- Deep link support for bookmarking direct SSH or file manager sessions
|
||||||
|
- ACME/Certbot SSL certificate support
|
||||||
|
- Import hosts from SSH config file
|
||||||
|
- OIDC with custom CA certificate support
|
||||||
|
- Open files directly in the file manager text editor
|
||||||
|
- File manager text editor Ctrl+W capture to prevent accidental tab close
|
||||||
|
- Option to collapse hosts to a single line in the sidebar
|
||||||
|
- Select a different backend Termix server from within the app
|
||||||
|
- Windows portable app now truly portable (no registry writes)
|
||||||
|
- Bundle fonts for offline environments
|
||||||
|
- Option to disable clickable links in the terminal
|
||||||
|
- Proxmox login options including OPKSSH
|
||||||
|
- UI suggestions and general interface improvements
|
||||||
|
- Per-protocol host metrics (online/offline detection) configuration
|
||||||
|
- Increase file manager max upload size by splicing files and reassembling them (~5GB)
|
||||||
<!-- /UPDATE_LOG -->
|
<!-- /UPDATE_LOG -->
|
||||||
|
|
||||||
<!-- BUG_FIXES -->
|
<!-- BUG_FIXES -->
|
||||||
|
|
||||||
- SFTP jump-host fallback from host data
|
- File transfers over 100MB failing
|
||||||
- Guacd password incorrectly passed for app view
|
- File transfers over 30 seconds aborted by axios timeout
|
||||||
- Admin page failing to load admin information
|
- SSH terminal through jump host chain timing out with malformed SSH messages
|
||||||
- Disabled hardware acceleration on Windows to prevent startup crash
|
- Cloning a host with SSH key auth not allowing auth method change on the clone
|
||||||
- Dashboard total credentials stuck at 0
|
- File deletion in the file manager affecting selected files in inactive tabs
|
||||||
- Host username ignored when credential attached
|
- File manager not connecting when cert passphrase is not saved
|
||||||
- Clone host cannot switch auth method
|
- File text editor cursor position lost on save
|
||||||
- File manager context menu off-screen
|
- macOS Option + Left/Right Arrow outputting raw ANSI sequences instead of moving cursor by word
|
||||||
- File delete affecting inactive tabs
|
- File manager tree view not sorted alphabetically
|
||||||
- Silent delete failure on Windows hosts
|
- SFTP failing with timeout when using a jump host chain
|
||||||
- iPad host tab does nothing
|
- SSH key auth with Duo not showing prompt and failing immediately
|
||||||
- Docker console terminal background using incorrect colors
|
- Enabling 2FA with password login disabled causing a login deadlock
|
||||||
- Reliable OIDC group syncing for admin roles
|
- Failed to disable 2FA even when providing correct TOTP or password
|
||||||
- Guacd connections using incorrect screen height
|
- Arrow buttons not working in Midnight Commander on the Android app
|
||||||
- 2FA failing to disable
|
- Credential deploy command copy failing silently (clipboard API unavailable in some browsers)
|
||||||
- Hostname fill entire column and truncate at proper spot in dashboard
|
- Disabling password login still showing the password login form
|
||||||
- Make credentials start collapsed
|
- Folder picker in the new host form not showing existing folders
|
||||||
- Incorrect JSDoc comments
|
- Command palette always opening hosts as SSH terminal regardless of protocol settings
|
||||||
|
- Saving SSH credentials failing on fresh installs
|
||||||
|
- Import hosts failing when hosts use SSH key credentials
|
||||||
|
- Warpgate authentication prompting for a password unnecessarily
|
||||||
|
- File manager folder icon not appearing under host name on hover
|
||||||
|
- File manager delete silently failing on Windows hosts (rm -f not supported by PowerShell)
|
||||||
|
- SSH terminal failing with keepalive timeout when server MOTD is slow to load
|
||||||
|
- SSH connection via SOCKS5 proxy failing after update
|
||||||
|
- Linking an OIDC account to a password account not working
|
||||||
|
- User password copy missing and RBAC issues in admin panel
|
||||||
|
- Debian and Android packages not opening the server on launch
|
||||||
|
- Username field in host edit and new host form having no effect
|
||||||
|
- Mobile terminal crashing on iOS with React error 130
|
||||||
|
- Network interface symbols incorrect in host metrics
|
||||||
|
- Sudo password auto-fill not working on Ubuntu Server 26.04
|
||||||
|
- get_cwd command injecting into live PTY and corrupting interactive programs
|
||||||
|
- Initial directory command failing on Windows PowerShell SSH targets
|
||||||
|
- 3-way split not working with layout issues
|
||||||
|
- Docker management not working for Windows hosts
|
||||||
|
- Docker management failing on Debian with exit code 1
|
||||||
|
- Docker logs not respecting the current theme
|
||||||
|
- API not responding correctly after update
|
||||||
|
- Caddy reverse proxy configuration not working
|
||||||
|
- Wrong keyboard layout in VNC sessions
|
||||||
|
- KDE scaling issues in the desktop app
|
||||||
|
- Linux app failing to start due to better-sqlite3 Node version mismatch
|
||||||
|
- Tab autocomplete not working in the macOS x86 app
|
||||||
|
- Cloudflare SSL tunnels with third-level subdomains blocking Termix web portal access
|
||||||
|
- Fzf completion not working in the Android app
|
||||||
|
- SSH terminal frame delivery jitter in Docker (ssh2 native crypto not compiled)
|
||||||
|
- OIDC login failing in Linux and Android apps when Authentik has a Captcha stage
|
||||||
|
- Android app crashing after entering password when auth is set to None
|
||||||
|
- Editing or saving a host clearing the password for RDP and VNC connections
|
||||||
|
- Hardcoded 30-minute open-tabs TTL defeating session persistence
|
||||||
|
- Keyboard mapping issues on Windows Server 2019 via RDP
|
||||||
|
- Shared server not appearing for other users
|
||||||
|
- RBAC role assignment failing for OIDC users
|
||||||
|
- SSO configuration broken when supplied via environment variables
|
||||||
|
- RDP requiring credentials even when none are needed
|
||||||
|
- Terminal outputting success right after folder path
|
||||||
|
- GitHub/Google SSO provider giving ERR_INVALID_URL
|
||||||
|
- Keyboard focus not on main screen when selecting tmux session
|
||||||
|
- Remove all references to SALT variable
|
||||||
|
- Syntax highlighter duplicating path, visual corruptions, cursor jumps, etc.
|
||||||
|
- RDP session screen clips/spills over on viewport resize
|
||||||
<!-- /BUG_FIXES -->
|
<!-- /BUG_FIXES -->
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
exports.default = async function afterPack(context) {
|
||||||
|
const { targets, appOutDir } = context;
|
||||||
|
|
||||||
|
const isDir = targets.some((t) => t.name === "dir");
|
||||||
|
if (!isDir) return;
|
||||||
|
|
||||||
|
const markerPath = path.join(appOutDir, ".portable");
|
||||||
|
fs.writeFileSync(markerPath, "");
|
||||||
|
};
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies
|
# Stage 1: Install dependencies
|
||||||
FROM node:24-slim AS deps
|
FROM node:26-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies only
|
# Stage 4: Production dependencies only
|
||||||
FROM node:24-slim AS production-deps
|
FROM node:26-slim AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -53,14 +53,14 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Final optimized image
|
# Stage 5: Final optimized image
|
||||||
FROM node:24-slim
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
|
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
|
||||||
update-ca-certificates && \
|
update-ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
|
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fi
|
|||||||
mkdir -p /tmp/nginx
|
mkdir -p /tmp/nginx
|
||||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
|
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
|
||||||
|
|
||||||
mkdir -p /app/data /app/uploads /app/data/.opk
|
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
|
||||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||||
|
|
||||||
if [ -w /app/data ]; then
|
if [ -w /app/data ]; then
|
||||||
|
|||||||
+59
-40
@@ -24,7 +24,11 @@ http {
|
|||||||
client_header_timeout 300s;
|
client_header_timeout 300s;
|
||||||
|
|
||||||
set_real_ip_from 127.0.0.1;
|
set_real_ip_from 127.0.0.1;
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
real_ip_header X-Forwarded-For;
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
|
||||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||||
default $http_x_forwarded_proto;
|
default $http_x_forwarded_proto;
|
||||||
@@ -67,6 +71,12 @@ http {
|
|||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /app/data/acme-webroot;
|
||||||
|
default_type "text/plain";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location = /sw.js {
|
location = /sw.js {
|
||||||
root /app/html;
|
root /app/html;
|
||||||
expires off;
|
expires off;
|
||||||
@@ -128,7 +138,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/releases(/.*)?$ {
|
location ~ ^/releases(/.*)?$ {
|
||||||
@@ -137,7 +147,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/alerts(/.*)?$ {
|
location ~ ^/alerts(/.*)?$ {
|
||||||
@@ -146,7 +156,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/rbac(/.*)?$ {
|
location ~ ^/rbac(/.*)?$ {
|
||||||
@@ -155,7 +165,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/credentials(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
@@ -164,7 +174,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -177,7 +187,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||||
@@ -186,7 +196,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/audit-logs(/.*)?$ {
|
location ~ ^/audit-logs(/.*)?$ {
|
||||||
@@ -195,7 +205,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/terminal(/.*)?$ {
|
location ~ ^/terminal(/.*)?$ {
|
||||||
@@ -204,7 +214,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/open-tabs(/.*)?$ {
|
location ~ ^/open-tabs(/.*)?$ {
|
||||||
@@ -213,7 +223,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/user-preferences(/.*)?$ {
|
location ~ ^/user-preferences(/.*)?$ {
|
||||||
@@ -222,7 +232,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/database(/.*)?$ {
|
location ~ ^/database(/.*)?$ {
|
||||||
@@ -234,7 +244,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -253,7 +263,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -269,7 +279,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/quick-connect {
|
location /host/quick-connect {
|
||||||
@@ -281,7 +291,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||||
@@ -320,7 +330,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /session_logs/ {
|
location /session_logs/ {
|
||||||
@@ -329,7 +339,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /tailscale/ {
|
location /tailscale/ {
|
||||||
@@ -338,7 +348,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
@@ -377,7 +387,7 @@ http {
|
|||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
@@ -397,7 +407,7 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
@@ -406,7 +416,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
location /ssh/tunnel/ {
|
||||||
@@ -417,7 +427,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
@@ -430,7 +440,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/pinned {
|
location /host/file_manager/pinned {
|
||||||
@@ -439,7 +449,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/shortcuts {
|
location /host/file_manager/shortcuts {
|
||||||
@@ -448,7 +458,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/sudo-password {
|
location /host/file_manager/sudo-password {
|
||||||
@@ -457,7 +467,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ {
|
location /ssh/file_manager/ {
|
||||||
@@ -471,7 +481,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -492,7 +502,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -508,7 +518,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
@@ -517,7 +527,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
location ~ ^/status(/.*)?$ {
|
||||||
@@ -526,7 +536,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/metrics(/.*)?$ {
|
location ~ ^/metrics(/.*)?$ {
|
||||||
@@ -535,7 +545,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
@@ -548,7 +558,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/host-metrics(/.*)?$ {
|
location ~ ^/host-metrics(/.*)?$ {
|
||||||
@@ -557,7 +567,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
proxy_connect_timeout 600s;
|
||||||
proxy_send_timeout 600s;
|
proxy_send_timeout 600s;
|
||||||
@@ -570,7 +580,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
@@ -579,7 +589,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/activity(/.*)?$ {
|
location ~ ^/activity(/.*)?$ {
|
||||||
@@ -588,7 +598,16 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/service-links(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
@@ -602,7 +621,7 @@ http {
|
|||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
@@ -623,7 +642,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -637,7 +656,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
|
|||||||
+60
-41
@@ -24,7 +24,11 @@ http {
|
|||||||
client_header_timeout 300s;
|
client_header_timeout 300s;
|
||||||
|
|
||||||
set_real_ip_from 127.0.0.1;
|
set_real_ip_from 127.0.0.1;
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
real_ip_header X-Forwarded-For;
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
|
||||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||||
default $http_x_forwarded_proto;
|
default $http_x_forwarded_proto;
|
||||||
@@ -56,6 +60,12 @@ http {
|
|||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /app/data/acme-webroot;
|
||||||
|
default_type "text/plain";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location = /sw.js {
|
location = /sw.js {
|
||||||
root /app/html;
|
root /app/html;
|
||||||
expires off;
|
expires off;
|
||||||
@@ -117,7 +127,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/releases(/.*)?$ {
|
location ~ ^/releases(/.*)?$ {
|
||||||
@@ -126,7 +136,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/alerts(/.*)?$ {
|
location ~ ^/alerts(/.*)?$ {
|
||||||
@@ -135,7 +145,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/rbac(/.*)?$ {
|
location ~ ^/rbac(/.*)?$ {
|
||||||
@@ -144,7 +154,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/credentials(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
@@ -153,7 +163,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -166,7 +176,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/proxmox(/.*)?$ {
|
location ~ ^/proxmox(/.*)?$ {
|
||||||
@@ -175,7 +185,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout 120s;
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout 120s;
|
||||||
@@ -187,7 +197,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/audit-logs(/.*)?$ {
|
location ~ ^/audit-logs(/.*)?$ {
|
||||||
@@ -196,7 +206,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/terminal(/.*)?$ {
|
location ~ ^/terminal(/.*)?$ {
|
||||||
@@ -205,7 +215,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/open-tabs(/.*)?$ {
|
location ~ ^/open-tabs(/.*)?$ {
|
||||||
@@ -214,7 +224,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/user-preferences(/.*)?$ {
|
location ~ ^/user-preferences(/.*)?$ {
|
||||||
@@ -223,7 +233,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/database(/.*)?$ {
|
location ~ ^/database(/.*)?$ {
|
||||||
@@ -235,7 +245,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -254,7 +264,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -270,7 +280,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/quick-connect {
|
location /host/quick-connect {
|
||||||
@@ -282,7 +292,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||||
@@ -321,7 +331,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /session_logs/ {
|
location /session_logs/ {
|
||||||
@@ -330,7 +340,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /tailscale/ {
|
location /tailscale/ {
|
||||||
@@ -339,7 +349,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/websocket/ {
|
location /ssh/websocket/ {
|
||||||
@@ -378,7 +388,7 @@ http {
|
|||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
@@ -398,7 +408,7 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
@@ -407,7 +417,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
location /ssh/tunnel/ {
|
||||||
@@ -418,7 +428,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
@@ -431,7 +441,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/pinned {
|
location /host/file_manager/pinned {
|
||||||
@@ -440,7 +450,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/shortcuts {
|
location /host/file_manager/shortcuts {
|
||||||
@@ -449,7 +459,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /host/file_manager/sudo-password {
|
location /host/file_manager/sudo-password {
|
||||||
@@ -458,7 +468,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ {
|
location /ssh/file_manager/ {
|
||||||
@@ -472,7 +482,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -493,7 +503,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -509,7 +519,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /health {
|
location /health {
|
||||||
@@ -518,7 +528,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/status(/.*)?$ {
|
location ~ ^/status(/.*)?$ {
|
||||||
@@ -527,7 +537,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/metrics(/.*)?$ {
|
location ~ ^/metrics(/.*)?$ {
|
||||||
@@ -536,7 +546,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 60s;
|
proxy_send_timeout 60s;
|
||||||
@@ -549,7 +559,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/host-metrics(/.*)?$ {
|
location ~ ^/host-metrics(/.*)?$ {
|
||||||
@@ -558,7 +568,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 600s;
|
proxy_connect_timeout 600s;
|
||||||
proxy_send_timeout 600s;
|
proxy_send_timeout 600s;
|
||||||
@@ -571,7 +581,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
@@ -580,7 +590,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/activity(/.*)?$ {
|
location ~ ^/activity(/.*)?$ {
|
||||||
@@ -589,7 +599,16 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/service-links(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
@@ -603,7 +622,7 @@ http {
|
|||||||
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
@@ -624,7 +643,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
@@ -638,7 +657,7 @@ http {
|
|||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
"artifactName": "termix_macos_${arch}_dmg.${ext}",
|
"artifactName": "termix_macos_${arch}_dmg.${ext}",
|
||||||
"sign": true
|
"sign": true
|
||||||
},
|
},
|
||||||
|
"afterPack": "build/after-pack.cjs",
|
||||||
"afterSign": "build/notarize.cjs",
|
"afterSign": "build/notarize.cjs",
|
||||||
"mas": {
|
"mas": {
|
||||||
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
|
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ const { URL } = require("url");
|
|||||||
const { fork } = require("child_process");
|
const { fork } = require("child_process");
|
||||||
const WebSocket = require("ws");
|
const WebSocket = require("ws");
|
||||||
|
|
||||||
|
// Portable mode: if a `.portable` marker exists next to the executable,
|
||||||
|
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
||||||
|
(function setupPortableDataDir() {
|
||||||
|
const exeDir = path.dirname(process.execPath);
|
||||||
|
const markerPath = path.join(exeDir, ".portable");
|
||||||
|
const envOverride = process.env.TERMIX_DATA_DIR;
|
||||||
|
|
||||||
|
let portableDataDir = null;
|
||||||
|
if (envOverride) {
|
||||||
|
portableDataDir = envOverride;
|
||||||
|
} else if (app.isPackaged && fs.existsSync(markerPath)) {
|
||||||
|
portableDataDir = path.join(exeDir, "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portableDataDir) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(portableDataDir)) {
|
||||||
|
fs.mkdirSync(portableDataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
app.setPath("userData", portableDataDir);
|
||||||
|
} catch {
|
||||||
|
// Fall back to default userData if we can't create the directory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
||||||
const electronAuthCookiesPath = path.join(
|
const electronAuthCookiesPath = path.join(
|
||||||
app.getPath("userData"),
|
app.getPath("userData"),
|
||||||
@@ -476,6 +502,11 @@ if (process.platform === "linux") {
|
|||||||
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
||||||
|
|
||||||
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
||||||
|
|
||||||
|
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
|
||||||
|
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
|
||||||
|
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
|
||||||
|
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
|
|||||||
Generated
+1143
-4198
File diff suppressed because it is too large
Load Diff
+21
-18
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.4.0",
|
"version": "2.4.1",
|
||||||
"description": "Self-hosted SSH and remote desktop management.",
|
"description": "Self-hosted SSH and remote desktop management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
"axios": "^1.17.0",
|
"axios": "^1.18.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.11.1",
|
||||||
"body-parser": "^2.2.2",
|
"body-parser": "^2.3.0",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
@@ -59,13 +59,13 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.9",
|
"nanoid": "^5.1.9",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.4.0",
|
"undici": "^8.5.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -81,6 +81,9 @@
|
|||||||
"@electron/rebuild": "^4.0.4",
|
"@electron/rebuild": "^4.0.4",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource/fira-code": "^5.2.7",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource/source-code-pro": "^5.2.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.13",
|
"@radix-ui/react-accordion": "^1.2.13",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
@@ -91,11 +94,11 @@
|
|||||||
"@radix-ui/react-popover": "^1.1.16",
|
"@radix-ui/react-popover": "^1.1.16",
|
||||||
"@radix-ui/react-progress": "^1.1.9",
|
"@radix-ui/react-progress": "^1.1.9",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.3.1",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.9",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.4.1",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.3.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
@@ -133,9 +136,9 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cytoscape": "^3.33.2",
|
"cytoscape": "^3.34.0",
|
||||||
"electron": "^42.2.0",
|
"electron": "^42.4.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
@@ -147,14 +150,14 @@
|
|||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^17.0.7",
|
"lint-staged": "^17.0.7",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.20.0",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.6.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-h5-audio-player": "^3.10.2",
|
"react-h5-audio-player": "^3.10.2",
|
||||||
"react-hook-form": "^7.73.1",
|
"react-hook-form": "^7.79.0",
|
||||||
"react-i18next": "^17.0.4",
|
"react-i18next": "^17.0.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -163,13 +166,13 @@
|
|||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.59.0",
|
"typescript-eslint": "^8.61.1",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-svgr": "^5.2.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import express from "express";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||||
import { getDb } from "./database/db/index.js";
|
import { getDb } from "./database/db/index.js";
|
||||||
import { recentActivity, hosts, hostAccess } from "./database/db/schema.js";
|
import {
|
||||||
import { eq, and, desc, inArray } from "drizzle-orm";
|
recentActivity,
|
||||||
|
hosts,
|
||||||
|
hostAccess,
|
||||||
|
userRoles,
|
||||||
|
} from "./database/db/schema.js";
|
||||||
|
import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm";
|
||||||
import { dashboardLogger } from "./utils/logger.js";
|
import { dashboardLogger } from "./utils/logger.js";
|
||||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||||
import { AuthManager } from "./utils/auth-manager.js";
|
import { AuthManager } from "./utils/auth-manager.js";
|
||||||
import type { AuthenticatedRequest } from "../types/index.js";
|
import type { AuthenticatedRequest } from "../types/index.js";
|
||||||
|
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -227,11 +233,30 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (ownedHosts.length === 0) {
|
if (ownedHosts.length === 0) {
|
||||||
|
const userRoleIds = await getDb()
|
||||||
|
.select({ roleId: userRoles.roleId })
|
||||||
|
.from(userRoles)
|
||||||
|
.where(eq(userRoles.userId, userId));
|
||||||
|
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
const sharedHosts = await getDb()
|
const sharedHosts = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(hostAccess)
|
.from(hostAccess)
|
||||||
.where(
|
.where(
|
||||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
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)),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sharedHosts.length === 0) {
|
if (sharedHosts.length === 0) {
|
||||||
@@ -349,6 +374,8 @@ app.delete("/activity/reset", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use("/service-links", dashboardServiceLinksRouter);
|
||||||
|
|
||||||
const PORT = 30006;
|
const PORT = 30006;
|
||||||
app.listen(PORT, async () => {
|
app.listen(PORT, async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1832,7 +1832,11 @@ if (frontendDist) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (req.method === "GET" && req.accepts("html")) {
|
if (
|
||||||
|
req.method === "GET" &&
|
||||||
|
req.accepts("html") &&
|
||||||
|
!req.headers.authorization
|
||||||
|
) {
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Cache-Control",
|
"Cache-Control",
|
||||||
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||||
|
|
||||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
@@ -753,6 +754,11 @@ const migrateSchema = () => {
|
|||||||
"enable_file_manager",
|
"enable_file_manager",
|
||||||
"INTEGER NOT NULL DEFAULT 1",
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
);
|
);
|
||||||
|
addColumnIfNotExists(
|
||||||
|
"ssh_data",
|
||||||
|
"scp_legacy",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
);
|
||||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||||
addColumnIfNotExists(
|
addColumnIfNotExists(
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -1197,6 +1203,14 @@ const migrateSchema = () => {
|
|||||||
{ column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
|
{ column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
|
||||||
{ column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
|
{ column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
|
||||||
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
|
{ column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
|
||||||
|
{ column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
|
||||||
|
{ column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
|
||||||
|
{ column: "wol_broadcast_address", sql: "ALTER TABLE ssh_data ADD COLUMN wol_broadcast_address TEXT" },
|
||||||
|
{ column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" },
|
||||||
|
{ column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
|
||||||
|
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
||||||
|
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
||||||
|
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const migration of sshDataMigrations) {
|
for (const migration of sshDataMigrations) {
|
||||||
@@ -1214,6 +1228,23 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate legacy authType="warpgate" hosts to useWarpgate=1 with authType="none"
|
||||||
|
try {
|
||||||
|
const result = sqlite
|
||||||
|
.prepare("UPDATE ssh_data SET use_warpgate = 1, auth_type = 'none' WHERE auth_type = 'warpgate'")
|
||||||
|
.run();
|
||||||
|
if (result.changes > 0) {
|
||||||
|
databaseLogger.info(`Migrated ${result.changes} host(s) from authType='warpgate' to useWarpgate=true`, {
|
||||||
|
operation: "warpgate_auth_migration",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
databaseLogger.warn("Failed to migrate legacy warpgate authType hosts", {
|
||||||
|
operation: "warpgate_auth_migration",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
|
// Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
|
||||||
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
|
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
|
||||||
const usernameDomainBackfills = [
|
const usernameDomainBackfills = [
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
tags: text("tags"),
|
tags: text("tags"),
|
||||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||||
authType: text("auth_type").notNull(),
|
authType: text("auth_type").notNull(),
|
||||||
|
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
|
||||||
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
||||||
|
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
@@ -127,6 +128,7 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false),
|
||||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -168,17 +170,24 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
vncPort: integer("vnc_port").default(5900),
|
vncPort: integer("vnc_port").default(5900),
|
||||||
telnetPort: integer("telnet_port").default(23),
|
telnetPort: integer("telnet_port").default(23),
|
||||||
|
|
||||||
|
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||||
rdpUser: text("rdp_user"),
|
rdpUser: text("rdp_user"),
|
||||||
rdpPassword: text("rdp_password"),
|
rdpPassword: text("rdp_password"),
|
||||||
rdpDomain: text("rdp_domain"),
|
rdpDomain: text("rdp_domain"),
|
||||||
rdpSecurity: text("rdp_security"),
|
rdpSecurity: text("rdp_security"),
|
||||||
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
|
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
|
||||||
|
|
||||||
|
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||||
vncPassword: text("vnc_password"),
|
vncPassword: text("vnc_password"),
|
||||||
vncUser: text("vnc_user"),
|
vncUser: text("vnc_user"),
|
||||||
|
|
||||||
telnetUser: text("telnet_user"),
|
telnetUser: text("telnet_user"),
|
||||||
telnetPassword: text("telnet_password"),
|
telnetPassword: text("telnet_password"),
|
||||||
|
telnetCredentialId: integer("telnet_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||||
|
|
||||||
|
rdpAuthType: text("rdp_auth_type"),
|
||||||
|
vncAuthType: text("vnc_auth_type"),
|
||||||
|
telnetAuthType: text("telnet_auth_type"),
|
||||||
|
|
||||||
domain: text("domain"),
|
domain: text("domain"),
|
||||||
security: text("security"),
|
security: text("security"),
|
||||||
@@ -193,6 +202,7 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||||
|
|
||||||
macAddress: text("mac_address"),
|
macAddress: text("mac_address"),
|
||||||
|
wolBroadcastAddress: text("wol_broadcast_address"),
|
||||||
portKnockSequence: text("port_knock_sequence"),
|
portKnockSequence: text("port_knock_sequence"),
|
||||||
|
|
||||||
hostKeyFingerprint: text("host_key_fingerprint"),
|
hostKeyFingerprint: text("host_key_fingerprint"),
|
||||||
@@ -705,6 +715,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||||
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
||||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||||
|
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||||
|
statusColorScheme: text("status_color_scheme"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -763,6 +775,19 @@ export const hostHealthHistory = sqliteTable("host_health_history", {
|
|||||||
detail: text("detail"),
|
detail: text("detail"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
order: integer("order").notNull().default(0),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// --- tmux-monitor begin ---
|
// --- tmux-monitor begin ---
|
||||||
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
|
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { execSync } from "child_process";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import type { RequestHandler, Router } from "express";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { users } from "../db/schema.js";
|
||||||
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
|
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||||
|
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
|
||||||
|
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
|
||||||
|
DATA_DIR,
|
||||||
|
"ssl",
|
||||||
|
"cloudflare.ini",
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AcmeSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
domain: string;
|
||||||
|
email: string;
|
||||||
|
challengeType: "http-webroot" | "dns-cloudflare";
|
||||||
|
cloudflareToken: string;
|
||||||
|
lastIssuedAt: string | null;
|
||||||
|
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||||
|
certExpiresAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCertInfo(): {
|
||||||
|
status: "none" | "valid" | "expiring" | "expired";
|
||||||
|
expiresAt: string | null;
|
||||||
|
} {
|
||||||
|
const certFile = path.join(SSL_DIR, "termix.crt");
|
||||||
|
try {
|
||||||
|
execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { status: "none", expiresAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endDateRaw = execSync(
|
||||||
|
`openssl x509 -in "${certFile}" -noout -enddate`,
|
||||||
|
{ stdio: "pipe" },
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
.replace("notAfter=", "");
|
||||||
|
const expiresAt = new Date(endDateRaw).toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { status: "expired", expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
return { status: "valid", expiresAt };
|
||||||
|
} catch {
|
||||||
|
return { status: "expiring", expiresAt };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { status: "none", expiresAt: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcmeSettingsFromDb(): AcmeSettings {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
|
||||||
|
const { status, expiresAt } = getCertInfo();
|
||||||
|
const stored = row ? JSON.parse(row.value) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: stored.enabled ?? false,
|
||||||
|
domain: stored.domain ?? "",
|
||||||
|
email: stored.email ?? "",
|
||||||
|
challengeType: stored.challengeType ?? "http-webroot",
|
||||||
|
cloudflareToken: stored.cloudflareToken
|
||||||
|
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
|
||||||
|
: "",
|
||||||
|
lastIssuedAt: stored.lastIssuedAt ?? null,
|
||||||
|
certStatus: status,
|
||||||
|
certExpiresAt: expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAcmeSSLRoutes(
|
||||||
|
router: Router,
|
||||||
|
authenticateJWT: RequestHandler,
|
||||||
|
): void {
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/acme-ssl-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Get ACME SSL settings
|
||||||
|
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: ACME SSL settings and certificate status.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get ACME SSL settings.
|
||||||
|
*/
|
||||||
|
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getAcmeSettingsFromDb());
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get ACME SSL settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to get ACME SSL settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/acme-ssl-settings:
|
||||||
|
* patch:
|
||||||
|
* summary: Update ACME SSL settings (admin only)
|
||||||
|
* description: Saves ACME/Let's Encrypt configuration.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* domain:
|
||||||
|
* type: string
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* challengeType:
|
||||||
|
* type: string
|
||||||
|
* enum: [http-webroot, dns-cloudflare]
|
||||||
|
* cloudflareToken:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: ACME SSL settings updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update ACME SSL settings.
|
||||||
|
*/
|
||||||
|
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const current = existing ? JSON.parse(existing.value) : {};
|
||||||
|
|
||||||
|
const { enabled, domain, email, challengeType, cloudflareToken } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...(typeof enabled === "boolean" && { enabled }),
|
||||||
|
...(typeof domain === "string" && { domain }),
|
||||||
|
...(typeof email === "string" && { email }),
|
||||||
|
...(typeof challengeType === "string" && { challengeType }),
|
||||||
|
...(typeof cloudflareToken === "string" &&
|
||||||
|
cloudflareToken &&
|
||||||
|
!cloudflareToken.includes("*") && { cloudflareToken }),
|
||||||
|
};
|
||||||
|
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(updated));
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
const actorRecord = await db
|
||||||
|
.select({ username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actorRecord[0]?.username ?? userId,
|
||||||
|
action: "update_acme_ssl_settings",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({
|
||||||
|
enabled,
|
||||||
|
domain,
|
||||||
|
email,
|
||||||
|
challengeType,
|
||||||
|
hasCloudflareToken: !!updated.cloudflareToken,
|
||||||
|
}),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(getAcmeSettingsFromDb());
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update ACME SSL settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to update ACME SSL settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/acme-ssl-request:
|
||||||
|
* post:
|
||||||
|
* summary: Request or renew Let's Encrypt certificate (admin only)
|
||||||
|
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Certificate issued or renewed successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid configuration.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Certificate issuance failed.
|
||||||
|
*/
|
||||||
|
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'acme_ssl_settings'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(400).json({ error: "ACME settings not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = JSON.parse(row.value);
|
||||||
|
const { domain, email, challengeType, cloudflareToken } = settings;
|
||||||
|
|
||||||
|
if (!domain || !email) {
|
||||||
|
return res.status(400).json({ error: "Domain and email are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync("certbot --version", { stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "certbot is not available in this environment" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||||
|
await fs.mkdir(ACME_WEBROOT, { recursive: true });
|
||||||
|
|
||||||
|
let certbotCmd: string;
|
||||||
|
|
||||||
|
if (challengeType === "dns-cloudflare") {
|
||||||
|
if (!cloudflareToken) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Cloudflare API token is required for DNS challenge",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
await fs.writeFile(
|
||||||
|
CLOUDFLARE_CREDENTIALS_FILE,
|
||||||
|
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
certbotCmd = [
|
||||||
|
"certbot",
|
||||||
|
"certonly",
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"--dns-cloudflare",
|
||||||
|
`--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`,
|
||||||
|
"--dns-cloudflare-propagation-seconds",
|
||||||
|
"30",
|
||||||
|
"-d",
|
||||||
|
`"${domain}"`,
|
||||||
|
"--email",
|
||||||
|
`"${email}"`,
|
||||||
|
"--cert-name",
|
||||||
|
"termix",
|
||||||
|
].join(" ");
|
||||||
|
} else {
|
||||||
|
certbotCmd = [
|
||||||
|
"certbot",
|
||||||
|
"certonly",
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"--webroot",
|
||||||
|
"-w",
|
||||||
|
`"${ACME_WEBROOT}"`,
|
||||||
|
"-d",
|
||||||
|
`"${domain}"`,
|
||||||
|
"--email",
|
||||||
|
`"${email}"`,
|
||||||
|
"--cert-name",
|
||||||
|
"termix",
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||||
|
domain,
|
||||||
|
challengeType,
|
||||||
|
operation: "acme_cert_request",
|
||||||
|
});
|
||||||
|
|
||||||
|
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
|
||||||
|
|
||||||
|
const liveDir = `/etc/letsencrypt/live/termix`;
|
||||||
|
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
||||||
|
const privkeySrc = path.join(liveDir, "privkey.pem");
|
||||||
|
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||||
|
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||||
|
|
||||||
|
await fs.copyFile(fullchainSrc, certDest);
|
||||||
|
await fs.copyFile(privkeySrc, keyDest);
|
||||||
|
await fs.chmod(keyDest, 0o600);
|
||||||
|
await fs.chmod(certDest, 0o644);
|
||||||
|
|
||||||
|
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('acme_ssl_settings', ?)",
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(updated));
|
||||||
|
|
||||||
|
authLogger.info("Let's Encrypt certificate issued and installed", {
|
||||||
|
domain,
|
||||||
|
operation: "acme_cert_installed",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
const actorRecord = await db
|
||||||
|
.select({ username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actorRecord[0]?.username ?? userId,
|
||||||
|
action: "acme_ssl_request",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ domain, challengeType, success: true }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, ...getAcmeSettingsFromDb() });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
authLogger.error("ACME certificate request failed", err);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
const actorRecord = await db
|
||||||
|
.select({ username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actorRecord[0]?.username ?? userId,
|
||||||
|
action: "acme_ssl_request",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ error: message }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -154,7 +154,9 @@ router.post(
|
|||||||
error: keyInfo.error,
|
error: keyInfo.error,
|
||||||
});
|
});
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Invalid SSH key: ${keyInfo.error}`,
|
error: keyInfo.error
|
||||||
|
? `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,7 +527,9 @@ router.put(
|
|||||||
error: keyInfo.error,
|
error: keyInfo.error,
|
||||||
});
|
});
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Invalid SSH key: ${keyInfo.error}`,
|
error: keyInfo.error
|
||||||
|
? `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
: "Unrecognized SSH key format. Only OpenSSH and PEM formats are supported (not PuTTY .ppk).",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updateFields.privateKey = keyInfo.privateKey;
|
updateFields.privateKey = keyInfo.privateKey;
|
||||||
@@ -986,7 +990,7 @@ function formatSSHHostOutput(
|
|||||||
tunnelConnections: host.tunnelConnections
|
tunnelConnections: host.tunnelConnections
|
||||||
? JSON.parse(host.tunnelConnections as string)
|
? JSON.parse(host.tunnelConnections as string)
|
||||||
: [],
|
: [],
|
||||||
enableFileManager: !!host.enableFileManager,
|
enableFileManager: host.enableFileManager !== false,
|
||||||
defaultPath: host.defaultPath,
|
defaultPath: host.defaultPath,
|
||||||
createdAt: host.createdAt,
|
createdAt: host.createdAt,
|
||||||
updatedAt: host.updatedAt,
|
updatedAt: host.updatedAt,
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
|
import { dashboardLogger } from "../../utils/logger.js";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import { dashboardServiceLinks } from "../db/schema.js";
|
||||||
|
import { isNonEmptyString } from "./host-normalizers.js";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
export const dashboardServiceLinksRouter = express.Router();
|
||||||
|
|
||||||
|
function isValidUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /service-links:
|
||||||
|
* get:
|
||||||
|
* summary: Get service links
|
||||||
|
* description: Returns all dashboard service links for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of service links.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to fetch service links.
|
||||||
|
*/
|
||||||
|
dashboardServiceLinksRouter.get("/", async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const links = await db
|
||||||
|
.select()
|
||||||
|
.from(dashboardServiceLinks)
|
||||||
|
.where(eq(dashboardServiceLinks.userId, userId))
|
||||||
|
.orderBy(asc(dashboardServiceLinks.order), asc(dashboardServiceLinks.id));
|
||||||
|
res.json(links);
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to fetch service links", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch service links" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /service-links:
|
||||||
|
* post:
|
||||||
|
* summary: Create service link
|
||||||
|
* description: Creates a new dashboard service link for the authenticated user.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - label
|
||||||
|
* - url
|
||||||
|
* properties:
|
||||||
|
* label:
|
||||||
|
* type: string
|
||||||
|
* url:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: Service link created.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid data.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create service link.
|
||||||
|
*/
|
||||||
|
dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { label, url } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(label) || !isNonEmptyString(url)) {
|
||||||
|
return res.status(400).json({ error: "label and url are required" });
|
||||||
|
}
|
||||||
|
if (!isValidUrl(url)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "url must be a valid http or https URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select({ order: dashboardServiceLinks.order })
|
||||||
|
.from(dashboardServiceLinks)
|
||||||
|
.where(eq(dashboardServiceLinks.userId, userId))
|
||||||
|
.orderBy(asc(dashboardServiceLinks.order));
|
||||||
|
|
||||||
|
const nextOrder =
|
||||||
|
existing.length > 0 ? existing[existing.length - 1].order + 1 : 0;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(dashboardServiceLinks)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
label: label.trim(),
|
||||||
|
url: url.trim(),
|
||||||
|
order: nextOrder,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to create service link", err);
|
||||||
|
res.status(500).json({ error: "Failed to create service link" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /service-links/{id}:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete service link
|
||||||
|
* description: Deletes a dashboard service link by ID.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Service link deleted.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid id.
|
||||||
|
* 404:
|
||||||
|
* description: Not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete service link.
|
||||||
|
*/
|
||||||
|
dashboardServiceLinksRouter.delete(
|
||||||
|
"/:id",
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const idParam = Array.isArray(req.params.id)
|
||||||
|
? req.params.id[0]
|
||||||
|
: req.params.id;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ error: "Invalid id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(dashboardServiceLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardServiceLinks.id, id),
|
||||||
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(dashboardServiceLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardServiceLinks.id, id),
|
||||||
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: "Service link deleted" });
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to delete service link", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete service link" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /service-links/{id}:
|
||||||
|
* put:
|
||||||
|
* summary: Update service link
|
||||||
|
* description: Updates label or url of a dashboard service link.
|
||||||
|
* tags:
|
||||||
|
* - Dashboard
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* label:
|
||||||
|
* type: string
|
||||||
|
* url:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Service link updated.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid data.
|
||||||
|
* 404:
|
||||||
|
* description: Not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update service link.
|
||||||
|
*/
|
||||||
|
dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const idParam = Array.isArray(req.params.id)
|
||||||
|
? req.params.id[0]
|
||||||
|
: req.params.id;
|
||||||
|
const id = parseInt(idParam);
|
||||||
|
const { label, url } = req.body;
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({ error: "Invalid id" });
|
||||||
|
}
|
||||||
|
if (url !== undefined && !isValidUrl(url)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "url must be a valid http or https URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(dashboardServiceLinks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardServiceLinks.id, id),
|
||||||
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Partial<{ label: string; url: string }> = {};
|
||||||
|
if (isNonEmptyString(label)) updates.label = label.trim();
|
||||||
|
if (isNonEmptyString(url)) updates.url = url.trim();
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return res.status(400).json({ error: "Nothing to update" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(dashboardServiceLinks)
|
||||||
|
.set(updates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(dashboardServiceLinks.id, id),
|
||||||
|
eq(dashboardServiceLinks.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
dashboardLogger.error("Failed to update service link", err);
|
||||||
|
res.status(500).json({ error: "Failed to update service link" });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseSSHConfig } from "./host-bulk-routes.js";
|
||||||
|
|
||||||
|
describe("parseSSHConfig", () => {
|
||||||
|
it("parses a basic Host block", () => {
|
||||||
|
const config = `
|
||||||
|
Host myserver
|
||||||
|
HostName 192.168.1.10
|
||||||
|
User alice
|
||||||
|
Port 2222
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
name: "myserver",
|
||||||
|
hostname: "192.168.1.10",
|
||||||
|
user: "alice",
|
||||||
|
port: 2222,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple Host blocks", () => {
|
||||||
|
const config = `
|
||||||
|
Host web
|
||||||
|
HostName web.example.com
|
||||||
|
User deploy
|
||||||
|
|
||||||
|
Host db
|
||||||
|
HostName db.example.com
|
||||||
|
User postgres
|
||||||
|
Port 5432
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].name).toBe("web");
|
||||||
|
expect(result[1].name).toBe("db");
|
||||||
|
expect(result[1].port).toBe(5432);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores comment lines", () => {
|
||||||
|
const config = `
|
||||||
|
# This is a comment
|
||||||
|
Host server
|
||||||
|
# Another comment
|
||||||
|
HostName 10.0.0.1
|
||||||
|
User root
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].hostname).toBe("10.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips wildcard Host entries", () => {
|
||||||
|
const config = `
|
||||||
|
Host *
|
||||||
|
ServerAliveInterval 60
|
||||||
|
|
||||||
|
Host prod
|
||||||
|
HostName prod.example.com
|
||||||
|
User ubuntu
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe("prod");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures IdentityFile and ProxyJump", () => {
|
||||||
|
const config = `
|
||||||
|
Host bastion
|
||||||
|
HostName bastion.example.com
|
||||||
|
User ec2-user
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
ProxyJump jumphost.example.com
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].identityFile).toBe("~/.ssh/id_rsa");
|
||||||
|
expect(result[0].proxyJump).toBe("jumphost.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips Host blocks without a HostName", () => {
|
||||||
|
const config = `
|
||||||
|
Host alias-only
|
||||||
|
User foo
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults port to undefined when not specified", () => {
|
||||||
|
const config = `
|
||||||
|
Host server
|
||||||
|
HostName 1.2.3.4
|
||||||
|
User root
|
||||||
|
`;
|
||||||
|
const result = parseSSHConfig(config);
|
||||||
|
expect(result[0].port).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty input", () => {
|
||||||
|
expect(parseSSHConfig("")).toHaveLength(0);
|
||||||
|
expect(parseSSHConfig(" \n\n ")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,68 @@ import {
|
|||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
} from "./host-normalizers.js";
|
} from "./host-normalizers.js";
|
||||||
|
|
||||||
|
type SSHConfigHost = {
|
||||||
|
name: string;
|
||||||
|
hostname?: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
identityFile?: string;
|
||||||
|
proxyJump?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||||
|
const results: SSHConfigHost[] = [];
|
||||||
|
let current: SSHConfigHost | null = null;
|
||||||
|
|
||||||
|
for (const rawLine of content.split("\n")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
|
||||||
|
const spaceIdx = line.indexOf(" ");
|
||||||
|
if (spaceIdx === -1) continue;
|
||||||
|
|
||||||
|
const key = line.slice(0, spaceIdx).toLowerCase();
|
||||||
|
const value = line.slice(spaceIdx + 1).trim();
|
||||||
|
|
||||||
|
if (key === "host") {
|
||||||
|
if (current && current.hostname) results.push(current);
|
||||||
|
// Skip wildcard patterns
|
||||||
|
if (value === "*" || value.includes("*") || value.includes("?")) {
|
||||||
|
current = null;
|
||||||
|
} else {
|
||||||
|
current = { name: value };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) continue;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "hostname":
|
||||||
|
current.hostname = value;
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
current.user = value;
|
||||||
|
break;
|
||||||
|
case "port": {
|
||||||
|
const p = Number.parseInt(value, 10);
|
||||||
|
if (p > 0 && p <= 65535) current.port = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "identityfile":
|
||||||
|
if (!current.identityFile) current.identityFile = value;
|
||||||
|
break;
|
||||||
|
case "proxyjump":
|
||||||
|
current.proxyJump = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && current.hostname) results.push(current);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerHostBulkRoutes(
|
export function registerHostBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
@@ -363,6 +425,12 @@ export function registerHostBulkRoutes(
|
|||||||
|
|
||||||
if (fallback.length > 0) {
|
if (fallback.length > 0) {
|
||||||
hostData.credentialId = fallback[0].id;
|
hostData.credentialId = fallback[0].id;
|
||||||
|
} else if (isNonEmptyString(hostData.key)) {
|
||||||
|
hostData.authType = "key";
|
||||||
|
hostData.credentialId = undefined;
|
||||||
|
} else if (isNonEmptyString(hostData.password)) {
|
||||||
|
hostData.authType = "password";
|
||||||
|
hostData.credentialId = undefined;
|
||||||
} else {
|
} else {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
@@ -519,4 +587,212 @@ export function registerHostBulkRoutes(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/ssh-config-import:
|
||||||
|
* post:
|
||||||
|
* summary: Import hosts from an OpenSSH config file
|
||||||
|
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
|
||||||
|
* tags:
|
||||||
|
* - SSH
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - content
|
||||||
|
* properties:
|
||||||
|
* content:
|
||||||
|
* type: string
|
||||||
|
* description: Raw text content of the SSH config file.
|
||||||
|
* overwrite:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Import completed.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/ssh-config-import",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { content, overwrite } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(content)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "content is required and must be a non-empty string",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: SSHConfigHost[];
|
||||||
|
try {
|
||||||
|
parsed = parseSSHConfig(content);
|
||||||
|
} catch (err) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Failed to parse SSH config file" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "No valid Host entries found in the SSH config file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length > 100) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Maximum 100 hosts allowed per import" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsToImport = parsed.map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.hostname,
|
||||||
|
port: h.port ?? 22,
|
||||||
|
username: h.user,
|
||||||
|
authType: h.identityFile ? "key" : undefined,
|
||||||
|
connectionType: "ssh",
|
||||||
|
enableSsh: true,
|
||||||
|
...(h.proxyJump
|
||||||
|
? {
|
||||||
|
jumpHosts: [{ host: h.proxyJump, port: 22 }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
success: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||||
|
if (overwrite) {
|
||||||
|
try {
|
||||||
|
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
|
||||||
|
db.select().from(hosts).where(eq(hosts.userId, userId)),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
existingHostMap = new Map();
|
||||||
|
for (const h of allHosts) {
|
||||||
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||||
|
existingHostMap.set(key, { id: h.id as number });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
existingHostMap = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < hostsToImport.length; i++) {
|
||||||
|
const hostData = normalizeImportedHost(
|
||||||
|
hostsToImport[i] as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshDataObj: Record<string, unknown> = {
|
||||||
|
userId,
|
||||||
|
connectionType: "ssh",
|
||||||
|
name: hostData.name || hostData.ip,
|
||||||
|
folder: "Default",
|
||||||
|
tags: "",
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username: hostData.username || null,
|
||||||
|
authType: hostData.authType || "none",
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
credentialId: null,
|
||||||
|
pin: false,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableTunnel: true,
|
||||||
|
enableFileManager: true,
|
||||||
|
enableDocker: false,
|
||||||
|
enableProxmox: false,
|
||||||
|
enableTmuxMonitor: false,
|
||||||
|
showTerminalInSidebar: 0,
|
||||||
|
showFileManagerInSidebar: 0,
|
||||||
|
showTunnelInSidebar: 0,
|
||||||
|
showDockerInSidebar: 0,
|
||||||
|
showServerStatsInSidebar: 0,
|
||||||
|
defaultPath: "/",
|
||||||
|
sudoPassword: null,
|
||||||
|
tunnelConnections: "[]",
|
||||||
|
jumpHosts: hostData.jumpHosts
|
||||||
|
? JSON.stringify(hostData.jumpHosts)
|
||||||
|
: null,
|
||||||
|
quickActions: null,
|
||||||
|
statsConfig: null,
|
||||||
|
dockerConfig: null,
|
||||||
|
proxmoxConfig: null,
|
||||||
|
terminalConfig: null,
|
||||||
|
forceKeyboardInteractive: "false",
|
||||||
|
notes: null,
|
||||||
|
useSocks5: 0,
|
||||||
|
socks5Host: null,
|
||||||
|
socks5Port: null,
|
||||||
|
socks5Username: null,
|
||||||
|
socks5Password: null,
|
||||||
|
socks5ProxyChain: null,
|
||||||
|
portKnockSequence: null,
|
||||||
|
overrideCredentialUsername: 0,
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await SimpleDBOps.update(
|
||||||
|
hosts,
|
||||||
|
"ssh_data",
|
||||||
|
eq(hosts.id, existing.id),
|
||||||
|
sshDataObj,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
|
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
|
||||||
|
results.success++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host "${parsed[i].name}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||||
|
success: results.success,
|
||||||
|
updated: results.updated,
|
||||||
|
skipped: results.skipped,
|
||||||
|
failed: results.failed,
|
||||||
|
errors: results.errors,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function registerHostInternalRoutes(router: Router): void {
|
|||||||
),
|
),
|
||||||
pin: !!host.pin,
|
pin: !!host.pin,
|
||||||
enableTerminal: !!host.enableTerminal,
|
enableTerminal: !!host.enableTerminal,
|
||||||
enableFileManager: !!host.enableFileManager,
|
enableFileManager: host.enableFileManager !== false,
|
||||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||||
@@ -155,7 +155,7 @@ export function registerHostInternalRoutes(router: Router): void {
|
|||||||
tunnelConnections: tunnelConnections,
|
tunnelConnections: tunnelConnections,
|
||||||
pin: !!host.pin,
|
pin: !!host.pin,
|
||||||
enableTerminal: !!host.enableTerminal,
|
enableTerminal: !!host.enableTerminal,
|
||||||
enableFileManager: !!host.enableFileManager,
|
enableFileManager: host.enableFileManager !== false,
|
||||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ export function registerHostNetworkRoutes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const host = await db
|
const host = await db
|
||||||
.select({ macAddress: hosts.macAddress })
|
.select({
|
||||||
|
macAddress: hosts.macAddress,
|
||||||
|
wolBroadcastAddress: hosts.wolBroadcastAddress,
|
||||||
|
})
|
||||||
.from(hosts)
|
.from(hosts)
|
||||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
@@ -116,7 +119,10 @@ export function registerHostNetworkRoutes(
|
|||||||
.json({ error: "No valid MAC address configured" });
|
.json({ error: "No valid MAC address configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendWakeOnLan(host.macAddress);
|
await sendWakeOnLan(
|
||||||
|
host.macAddress,
|
||||||
|
host.wolBroadcastAddress ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
sshLogger.info("Wake-on-LAN packet sent", {
|
sshLogger.info("Wake-on-LAN packet sent", {
|
||||||
operation: "wake_on_lan",
|
operation: "wake_on_lan",
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export function transformHostResponse(
|
|||||||
pin: !!host.pin,
|
pin: !!host.pin,
|
||||||
enableTerminal: !!host.enableTerminal,
|
enableTerminal: !!host.enableTerminal,
|
||||||
enableTunnel: !!host.enableTunnel,
|
enableTunnel: !!host.enableTunnel,
|
||||||
enableFileManager: !!host.enableFileManager,
|
enableFileManager: host.enableFileManager !== false,
|
||||||
enableDocker: !!host.enableDocker,
|
enableDocker: !!host.enableDocker,
|
||||||
enableProxmox: !!host.enableProxmox,
|
enableProxmox: !!host.enableProxmox,
|
||||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||||
@@ -293,6 +293,7 @@ export function transformHostResponse(
|
|||||||
? JSON.parse(host.proxmoxConfig as string)
|
? JSON.parse(host.proxmoxConfig as string)
|
||||||
: undefined,
|
: undefined,
|
||||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||||
|
useWarpgate: !!host.useWarpgate,
|
||||||
socks5ProxyChain: host.socks5ProxyChain
|
socks5ProxyChain: host.socks5ProxyChain
|
||||||
? JSON.parse(host.socks5ProxyChain as string)
|
? JSON.parse(host.socks5ProxyChain as string)
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ router.post(
|
|||||||
password,
|
password,
|
||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
|
useWarpgate,
|
||||||
credentialId,
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
@@ -153,6 +154,7 @@ router.post(
|
|||||||
enableTerminal,
|
enableTerminal,
|
||||||
enableTunnel,
|
enableTunnel,
|
||||||
enableFileManager,
|
enableFileManager,
|
||||||
|
scpLegacy,
|
||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
@@ -184,6 +186,7 @@ router.post(
|
|||||||
portKnockSequence,
|
portKnockSequence,
|
||||||
overrideCredentialUsername,
|
overrideCredentialUsername,
|
||||||
macAddress,
|
macAddress,
|
||||||
|
wolBroadcastAddress,
|
||||||
enableSsh,
|
enableSsh,
|
||||||
enableRdp,
|
enableRdp,
|
||||||
enableVnc,
|
enableVnc,
|
||||||
@@ -243,6 +246,7 @@ router.post(
|
|||||||
port,
|
port,
|
||||||
username: effectiveUsername,
|
username: effectiveUsername,
|
||||||
authType: effectiveAuthType,
|
authType: effectiveAuthType,
|
||||||
|
useWarpgate: useWarpgate ? 1 : 0,
|
||||||
credentialId: credentialId || null,
|
credentialId: credentialId || null,
|
||||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||||
pin: pin ? 1 : 0,
|
pin: pin ? 1 : 0,
|
||||||
@@ -256,6 +260,7 @@ router.post(
|
|||||||
? JSON.stringify(quickActions)
|
? JSON.stringify(quickActions)
|
||||||
: null,
|
: null,
|
||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
|
scpLegacy: scpLegacy ? 1 : 0,
|
||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
@@ -301,6 +306,7 @@ router.post(
|
|||||||
? JSON.stringify(socks5ProxyChain)
|
? JSON.stringify(socks5ProxyChain)
|
||||||
: null,
|
: null,
|
||||||
macAddress: macAddress || null,
|
macAddress: macAddress || null,
|
||||||
|
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
@@ -713,6 +719,7 @@ router.put(
|
|||||||
password,
|
password,
|
||||||
authMethod,
|
authMethod,
|
||||||
authType,
|
authType,
|
||||||
|
useWarpgate,
|
||||||
credentialId,
|
credentialId,
|
||||||
key,
|
key,
|
||||||
keyPassword,
|
keyPassword,
|
||||||
@@ -722,6 +729,7 @@ router.put(
|
|||||||
enableTerminal,
|
enableTerminal,
|
||||||
enableTunnel,
|
enableTunnel,
|
||||||
enableFileManager,
|
enableFileManager,
|
||||||
|
scpLegacy,
|
||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
@@ -753,6 +761,7 @@ router.put(
|
|||||||
portKnockSequence,
|
portKnockSequence,
|
||||||
overrideCredentialUsername,
|
overrideCredentialUsername,
|
||||||
macAddress,
|
macAddress,
|
||||||
|
wolBroadcastAddress,
|
||||||
enableSsh,
|
enableSsh,
|
||||||
enableRdp,
|
enableRdp,
|
||||||
enableVnc,
|
enableVnc,
|
||||||
@@ -809,6 +818,7 @@ router.put(
|
|||||||
port,
|
port,
|
||||||
username: effectiveUsername,
|
username: effectiveUsername,
|
||||||
authType: effectiveAuthType,
|
authType: effectiveAuthType,
|
||||||
|
useWarpgate: useWarpgate ? 1 : 0,
|
||||||
credentialId: credentialId || null,
|
credentialId: credentialId || null,
|
||||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||||
pin: pin ? 1 : 0,
|
pin: pin ? 1 : 0,
|
||||||
@@ -822,6 +832,7 @@ router.put(
|
|||||||
? JSON.stringify(quickActions)
|
? JSON.stringify(quickActions)
|
||||||
: null,
|
: null,
|
||||||
enableFileManager: enableFileManager ? 1 : 0,
|
enableFileManager: enableFileManager ? 1 : 0,
|
||||||
|
scpLegacy: scpLegacy ? 1 : 0,
|
||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
@@ -867,6 +878,7 @@ router.put(
|
|||||||
? JSON.stringify(socks5ProxyChain)
|
? JSON.stringify(socks5ProxyChain)
|
||||||
: null,
|
: null,
|
||||||
macAddress: macAddress || null,
|
macAddress: macAddress || null,
|
||||||
|
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||||
portKnockSequence: portKnockSequence
|
portKnockSequence: portKnockSequence
|
||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
@@ -952,10 +964,9 @@ router.put(
|
|||||||
sshDataObj.keyType = null;
|
sshDataObj.keyType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rdpPassword !== undefined) sshDataObj.rdpPassword = rdpPassword || null;
|
if (rdpPassword) sshDataObj.rdpPassword = rdpPassword;
|
||||||
if (vncPassword !== undefined) sshDataObj.vncPassword = vncPassword || null;
|
if (vncPassword) sshDataObj.vncPassword = vncPassword;
|
||||||
if (telnetPassword !== undefined)
|
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
|
||||||
sshDataObj.telnetPassword = telnetPassword || null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
@@ -988,6 +999,8 @@ router.put(
|
|||||||
.select({
|
.select({
|
||||||
userId: hosts.userId,
|
userId: hosts.userId,
|
||||||
credentialId: hosts.credentialId,
|
credentialId: hosts.credentialId,
|
||||||
|
rdpCredentialId: hosts.rdpCredentialId,
|
||||||
|
vncCredentialId: hosts.vncCredentialId,
|
||||||
authType: hosts.authType,
|
authType: hosts.authType,
|
||||||
})
|
})
|
||||||
.from(hosts)
|
.from(hosts)
|
||||||
@@ -1025,11 +1038,26 @@ router.put(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sshDataObj.credentialId !== undefined) {
|
{
|
||||||
if (
|
const newCredId =
|
||||||
hostRecord[0].credentialId !== null &&
|
sshDataObj.credentialId !== undefined
|
||||||
sshDataObj.credentialId === null
|
? sshDataObj.credentialId
|
||||||
) {
|
: hostRecord[0].credentialId;
|
||||||
|
const newRdpCredId =
|
||||||
|
sshDataObj.rdpCredentialId !== undefined
|
||||||
|
? sshDataObj.rdpCredentialId
|
||||||
|
: hostRecord[0].rdpCredentialId;
|
||||||
|
const newVncCredId =
|
||||||
|
sshDataObj.vncCredentialId !== undefined
|
||||||
|
? sshDataObj.vncCredentialId
|
||||||
|
: hostRecord[0].vncCredentialId;
|
||||||
|
const hadCredential =
|
||||||
|
hostRecord[0].credentialId !== null ||
|
||||||
|
hostRecord[0].rdpCredentialId !== null ||
|
||||||
|
hostRecord[0].vncCredentialId !== null;
|
||||||
|
const willHaveCredential =
|
||||||
|
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
|
||||||
|
if (hadCredential && !willHaveCredential) {
|
||||||
await db
|
await db
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
.where(eq(hostAccess.hostId, Number(hostId)));
|
.where(eq(hostAccess.hostId, Number(hostId)));
|
||||||
@@ -1169,6 +1197,7 @@ router.get(
|
|||||||
tunnelConnections: hosts.tunnelConnections,
|
tunnelConnections: hosts.tunnelConnections,
|
||||||
jumpHosts: hosts.jumpHosts,
|
jumpHosts: hosts.jumpHosts,
|
||||||
enableFileManager: hosts.enableFileManager,
|
enableFileManager: hosts.enableFileManager,
|
||||||
|
scpLegacy: hosts.scpLegacy,
|
||||||
defaultPath: hosts.defaultPath,
|
defaultPath: hosts.defaultPath,
|
||||||
autostartPassword: hosts.autostartPassword,
|
autostartPassword: hosts.autostartPassword,
|
||||||
autostartKey: hosts.autostartKey,
|
autostartKey: hosts.autostartKey,
|
||||||
@@ -1203,6 +1232,7 @@ router.get(
|
|||||||
ignoreCert: hosts.ignoreCert,
|
ignoreCert: hosts.ignoreCert,
|
||||||
guacamoleConfig: hosts.guacamoleConfig,
|
guacamoleConfig: hosts.guacamoleConfig,
|
||||||
macAddress: hosts.macAddress,
|
macAddress: hosts.macAddress,
|
||||||
|
wolBroadcastAddress: hosts.wolBroadcastAddress,
|
||||||
dockerConfig: hosts.dockerConfig,
|
dockerConfig: hosts.dockerConfig,
|
||||||
proxmoxConfig: hosts.proxmoxConfig,
|
proxmoxConfig: hosts.proxmoxConfig,
|
||||||
enableSsh: hosts.enableSsh,
|
enableSsh: hosts.enableSsh,
|
||||||
@@ -1213,11 +1243,13 @@ router.get(
|
|||||||
rdpPort: hosts.rdpPort,
|
rdpPort: hosts.rdpPort,
|
||||||
vncPort: hosts.vncPort,
|
vncPort: hosts.vncPort,
|
||||||
telnetPort: hosts.telnetPort,
|
telnetPort: hosts.telnetPort,
|
||||||
|
rdpCredentialId: hosts.rdpCredentialId,
|
||||||
rdpUser: hosts.rdpUser,
|
rdpUser: hosts.rdpUser,
|
||||||
rdpPassword: hosts.rdpPassword,
|
rdpPassword: hosts.rdpPassword,
|
||||||
rdpDomain: hosts.rdpDomain,
|
rdpDomain: hosts.rdpDomain,
|
||||||
rdpSecurity: hosts.rdpSecurity,
|
rdpSecurity: hosts.rdpSecurity,
|
||||||
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
||||||
|
vncCredentialId: hosts.vncCredentialId,
|
||||||
vncUser: hosts.vncUser,
|
vncUser: hosts.vncUser,
|
||||||
vncPassword: hosts.vncPassword,
|
vncPassword: hosts.vncPassword,
|
||||||
telnetUser: hosts.telnetUser,
|
telnetUser: hosts.telnetUser,
|
||||||
@@ -1445,7 +1477,19 @@ router.get(
|
|||||||
|
|
||||||
const host = data[0];
|
const host = data[0];
|
||||||
const resolved = (await resolveHostCredentials(host, userId)) || host;
|
const resolved = (await resolveHostCredentials(host, userId)) || host;
|
||||||
const value = resolved[field];
|
let value = resolved[field];
|
||||||
|
|
||||||
|
if (!value && field === "sudoPassword" && resolved.terminalConfig) {
|
||||||
|
try {
|
||||||
|
const tc =
|
||||||
|
typeof resolved.terminalConfig === "string"
|
||||||
|
? JSON.parse(resolved.terminalConfig)
|
||||||
|
: resolved.terminalConfig;
|
||||||
|
value = tc?.sudoPassword || null;
|
||||||
|
} catch {
|
||||||
|
// malformed JSON — leave value null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return res.status(404).json({ error: "No password set" });
|
return res.status(404).json({ error: "No password set" });
|
||||||
@@ -1574,7 +1618,8 @@ router.get(
|
|||||||
!!resolvedHost.overrideCredentialUsername,
|
!!resolvedHost.overrideCredentialUsername,
|
||||||
enableTerminal: !!resolvedHost.enableTerminal,
|
enableTerminal: !!resolvedHost.enableTerminal,
|
||||||
enableTunnel: !!resolvedHost.enableTunnel,
|
enableTunnel: !!resolvedHost.enableTunnel,
|
||||||
enableFileManager: !!resolvedHost.enableFileManager,
|
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||||
|
scpLegacy: !!resolvedHost.scpLegacy,
|
||||||
enableDocker: !!resolvedHost.enableDocker,
|
enableDocker: !!resolvedHost.enableDocker,
|
||||||
enableProxmox: !!resolvedHost.enableProxmox,
|
enableProxmox: !!resolvedHost.enableProxmox,
|
||||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||||
@@ -1722,7 +1767,7 @@ router.get(
|
|||||||
!!resolvedHost.overrideCredentialUsername,
|
!!resolvedHost.overrideCredentialUsername,
|
||||||
enableTerminal: !!resolvedHost.enableTerminal,
|
enableTerminal: !!resolvedHost.enableTerminal,
|
||||||
enableTunnel: !!resolvedHost.enableTunnel,
|
enableTunnel: !!resolvedHost.enableTunnel,
|
||||||
enableFileManager: !!resolvedHost.enableFileManager,
|
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||||
enableDocker: !!resolvedHost.enableDocker,
|
enableDocker: !!resolvedHost.enableDocker,
|
||||||
enableProxmox: !!resolvedHost.enableProxmox,
|
enableProxmox: !!resolvedHost.enableProxmox,
|
||||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Router } from "express";
|
|||||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
|
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
@@ -298,15 +298,8 @@ export function registerLDAPAuthRoutes(router: Router): void {
|
|||||||
const isFirst = (countRow?.count || 0) === 0;
|
const isFirst = (countRow?.count || 0) === 0;
|
||||||
|
|
||||||
if (!isFirst && !autoProvision) {
|
if (!isFirst && !autoProvision) {
|
||||||
const regRow = db.$client
|
|
||||||
.prepare(
|
|
||||||
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
|
||||||
)
|
|
||||||
.get() as { value: string } | undefined;
|
|
||||||
if (regRow && regRow.value !== "true") {
|
|
||||||
return res.status(403).json({ error: "Registration is disabled" });
|
return res.status(403).json({ error: "Registration is disabled" });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
userId = nanoid();
|
userId = nanoid();
|
||||||
const isFirstFinal = db.$client.transaction(() => {
|
const isFirstFinal = db.$client.transaction(() => {
|
||||||
@@ -375,6 +368,39 @@ export function registerLDAPAuthRoutes(router: Router): void {
|
|||||||
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
|
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
|
||||||
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
|
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
|
||||||
existingUsers[0].isAdmin = isAdmin;
|
existingUsers[0].isAdmin = isAdmin;
|
||||||
|
try {
|
||||||
|
const newRoleName = isAdmin ? "admin" : "user";
|
||||||
|
const oldRoleName = isAdmin ? "user" : "admin";
|
||||||
|
const newRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, newRoleName))
|
||||||
|
.limit(1);
|
||||||
|
const oldRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, oldRoleName))
|
||||||
|
.limit(1);
|
||||||
|
if (oldRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, userId),
|
||||||
|
eq(userRoles.roleId, oldRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (newRole.length > 0) {
|
||||||
|
await db.insert(userRoles).values({
|
||||||
|
userId,
|
||||||
|
roleId: newRole[0].id,
|
||||||
|
grantedBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update display name if not dual-auth
|
// Update display name if not dual-auth
|
||||||
|
|||||||
@@ -23,7 +23,24 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
|||||||
* 200:
|
* 200:
|
||||||
* description: List of open tabs ordered by tab_order.
|
* description: List of open tabs ordered by tab_order.
|
||||||
*/
|
*/
|
||||||
const TAB_TTL_MS = 30 * 60 * 1000;
|
const DEFAULT_TAB_TTL_MINUTES = 30;
|
||||||
|
|
||||||
|
function getTabTtlMs(): number {
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||||
|
)
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
if (row) {
|
||||||
|
const minutes = parseInt(row.value, 10);
|
||||||
|
if (!isNaN(minutes) && minutes > 0) return minutes * 60_000;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB not available, use default
|
||||||
|
}
|
||||||
|
return DEFAULT_TAB_TTL_MINUTES * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy tab types that were renamed. Normalize on read so previously saved
|
// Legacy tab types that were renamed. Normalize on read so previously saved
|
||||||
// tabs still restore to the correct (renamed) tab type.
|
// tabs still restore to the correct (renamed) tab type.
|
||||||
@@ -38,7 +55,7 @@ function normalizeTabType(tabType: string): string {
|
|||||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
|
const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString();
|
||||||
const tabs = db
|
const tabs = db
|
||||||
.select()
|
.select()
|
||||||
.from(userOpenTabs)
|
.from(userOpenTabs)
|
||||||
|
|||||||
@@ -129,7 +129,12 @@ router.post(
|
|||||||
return res.status(403).json({ error: "Not host owner" });
|
return res.status(403).json({ error: "Not host owner" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!host[0].credentialId && host[0].authType !== "opkssh") {
|
if (
|
||||||
|
!host[0].credentialId &&
|
||||||
|
!host[0].rdpCredentialId &&
|
||||||
|
!host[0].vncCredentialId &&
|
||||||
|
host[0].authType !== "opkssh"
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error:
|
error:
|
||||||
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
||||||
@@ -204,21 +209,25 @@ router.post(
|
|||||||
.delete(sharedCredentials)
|
.delete(sharedCredentials)
|
||||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||||
|
|
||||||
if (host[0].credentialId) {
|
const activeCredentialId =
|
||||||
|
host[0].credentialId ??
|
||||||
|
host[0].rdpCredentialId ??
|
||||||
|
host[0].vncCredentialId;
|
||||||
|
if (activeCredentialId) {
|
||||||
const { SharedCredentialManager } =
|
const { SharedCredentialManager } =
|
||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-credential-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
if (targetType === "user") {
|
if (targetType === "user") {
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
await sharedCredManager.createSharedCredentialForUser(
|
||||||
existing[0].id,
|
existing[0].id,
|
||||||
host[0].credentialId,
|
activeCredentialId,
|
||||||
targetUserId!,
|
targetUserId!,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await sharedCredManager.createSharedCredentialsForRole(
|
await sharedCredManager.createSharedCredentialsForRole(
|
||||||
existing[0].id,
|
existing[0].id,
|
||||||
host[0].credentialId,
|
activeCredentialId,
|
||||||
targetRoleId!,
|
targetRoleId!,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -252,18 +261,22 @@ router.post(
|
|||||||
await import("../../utils/shared-credential-manager.js");
|
await import("../../utils/shared-credential-manager.js");
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
|
||||||
if (host[0].credentialId) {
|
const activeCredentialId =
|
||||||
|
host[0].credentialId ??
|
||||||
|
host[0].rdpCredentialId ??
|
||||||
|
host[0].vncCredentialId;
|
||||||
|
if (activeCredentialId) {
|
||||||
if (targetType === "user") {
|
if (targetType === "user") {
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
await sharedCredManager.createSharedCredentialForUser(
|
||||||
result.lastInsertRowid as number,
|
result.lastInsertRowid as number,
|
||||||
host[0].credentialId,
|
activeCredentialId,
|
||||||
targetUserId!,
|
targetUserId!,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await sharedCredManager.createSharedCredentialsForRole(
|
await sharedCredManager.createSharedCredentialsForRole(
|
||||||
result.lastInsertRowid as number,
|
result.lastInsertRowid as number,
|
||||||
host[0].credentialId,
|
activeCredentialId,
|
||||||
targetRoleId!,
|
targetRoleId!,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -487,6 +500,12 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const now = new Date().toISOString();
|
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 sharedHosts = await db
|
const sharedHosts = await db
|
||||||
.select({
|
.select({
|
||||||
id: hosts.id,
|
id: hosts.id,
|
||||||
@@ -506,7 +525,15 @@ router.get(
|
|||||||
.innerJoin(users, eq(hosts.userId, users.id))
|
.innerJoin(users, eq(hosts.userId, users.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
or(
|
||||||
eq(hostAccess.userId, userId),
|
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)),
|
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -924,6 +924,268 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /snippets/export:
|
||||||
|
* get:
|
||||||
|
* summary: Export all snippets and folders as JSON
|
||||||
|
* description: Returns all snippets and snippet folders for the authenticated user as a JSON export.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Export object containing snippets and folders arrays.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid userId.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to export snippets.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/export",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
authLogger.warn("Invalid userId for snippet export");
|
||||||
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allSnippets = await db
|
||||||
|
.select()
|
||||||
|
.from(snippets)
|
||||||
|
.where(eq(snippets.userId, userId))
|
||||||
|
.orderBy(asc(snippets.folder), asc(snippets.order));
|
||||||
|
|
||||||
|
const allFolders = await db
|
||||||
|
.select()
|
||||||
|
.from(snippetFolders)
|
||||||
|
.where(eq(snippetFolders.userId, userId))
|
||||||
|
.orderBy(asc(snippetFolders.name));
|
||||||
|
|
||||||
|
const exportedSnippets = allSnippets.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
content: s.content,
|
||||||
|
description: s.description,
|
||||||
|
folder: s.folder,
|
||||||
|
order: s.order,
|
||||||
|
hostFilter: s.hostFilter,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const exportedFolders = allFolders.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
color: f.color,
|
||||||
|
icon: f.icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
authLogger.success(`Snippets exported by user ${userId}`, {
|
||||||
|
operation: "snippet_export",
|
||||||
|
userId,
|
||||||
|
snippetCount: exportedSnippets.length,
|
||||||
|
folderCount: exportedFolders.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ snippets: exportedSnippets, folders: exportedFolders });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to export snippets", err);
|
||||||
|
res.status(500).json({ error: "Failed to export snippets" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /snippets/bulk-import:
|
||||||
|
* post:
|
||||||
|
* summary: Bulk import snippets and folders from JSON
|
||||||
|
* description: Imports snippets and folders. Existing folders are skipped; existing snippets (matched by name+folder) can be skipped or overwritten.
|
||||||
|
* tags:
|
||||||
|
* - Snippets
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* snippets:
|
||||||
|
* type: array
|
||||||
|
* folders:
|
||||||
|
* type: array
|
||||||
|
* overwrite:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Import results with counts.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request body.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to import snippets.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/bulk-import",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const {
|
||||||
|
snippets: snippetsToImport,
|
||||||
|
folders: foldersToImport,
|
||||||
|
overwrite,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid userId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(snippetsToImport) && !Array.isArray(foldersToImport)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "snippets or folders array is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
snippetsImported: 0,
|
||||||
|
snippetsSkipped: 0,
|
||||||
|
snippetsUpdated: 0,
|
||||||
|
foldersImported: 0,
|
||||||
|
foldersSkipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Array.isArray(foldersToImport)) {
|
||||||
|
for (const folder of foldersToImport) {
|
||||||
|
if (!isNonEmptyString(folder.name)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Folder missing name`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(snippetFolders)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippetFolders.userId, userId),
|
||||||
|
eq(snippetFolders.name, folder.name.trim()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
results.foldersSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(snippetFolders).values({
|
||||||
|
userId,
|
||||||
|
name: folder.name.trim(),
|
||||||
|
color: folder.color?.trim() || null,
|
||||||
|
icon: folder.icon?.trim() || null,
|
||||||
|
});
|
||||||
|
results.foldersImported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(snippetsToImport)) {
|
||||||
|
for (let i = 0; i < snippetsToImport.length; i++) {
|
||||||
|
const s = snippetsToImport[i];
|
||||||
|
|
||||||
|
if (!isNonEmptyString(s.name) || !isNonEmptyString(s.content)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Snippet ${i + 1}: name and content are required`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderVal = s.folder?.trim() || null;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(snippets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippets.userId, userId),
|
||||||
|
eq(snippets.name, s.name.trim()),
|
||||||
|
folderVal
|
||||||
|
? eq(snippets.folder, folderVal)
|
||||||
|
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
if (!overwrite) {
|
||||||
|
results.snippetsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(snippets)
|
||||||
|
.set({
|
||||||
|
content: s.content.trim(),
|
||||||
|
description: s.description?.trim() || null,
|
||||||
|
folder: folderVal,
|
||||||
|
order:
|
||||||
|
typeof s.order === "number" ? s.order : existing[0].order,
|
||||||
|
hostFilter: s.hostFilter || null,
|
||||||
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippets.id, existing[0].id),
|
||||||
|
eq(snippets.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
results.snippetsUpdated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxOrderResult = await db
|
||||||
|
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
||||||
|
.from(snippets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippets.userId, userId),
|
||||||
|
folderVal
|
||||||
|
? eq(snippets.folder, folderVal)
|
||||||
|
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const maxOrder = maxOrderResult[0]?.maxOrder ?? -1;
|
||||||
|
|
||||||
|
await db.insert(snippets).values({
|
||||||
|
userId,
|
||||||
|
name: s.name.trim(),
|
||||||
|
content: s.content.trim(),
|
||||||
|
description: s.description?.trim() || null,
|
||||||
|
folder: folderVal,
|
||||||
|
order: typeof s.order === "number" ? s.order : maxOrder + 1,
|
||||||
|
hostFilter: s.hostFilter || null,
|
||||||
|
});
|
||||||
|
results.snippetsImported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authLogger.success(`Snippets bulk-imported by user ${userId}`, {
|
||||||
|
operation: "snippet_bulk_import",
|
||||||
|
userId,
|
||||||
|
...results,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, ...results });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to bulk import snippets", err);
|
||||||
|
res.status(500).json({ error: "Failed to import snippets" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /snippets:
|
* /snippets:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { eq, asc } from "drizzle-orm";
|
|||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import type { SSOProviderType } from "../../../types/index.js";
|
import type { SSOProviderType } from "../../../types/index.js";
|
||||||
|
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
@@ -117,6 +118,16 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
.from(ssoProviders)
|
.from(ssoProviders)
|
||||||
.where(eq(ssoProviders.enabled, true))
|
.where(eq(ssoProviders.enabled, true))
|
||||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||||
|
|
||||||
|
// If no DB providers exist, synthesize one from env vars so SSO login
|
||||||
|
// remains available when configured purely via environment variables.
|
||||||
|
if (providers.length === 0) {
|
||||||
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
|
if (envConfig) {
|
||||||
|
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json(providers);
|
res.json(providers);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to list SSO providers", err);
|
authLogger.error("Failed to list SSO providers", err);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { users } from "../db/schema.js";
|
import { users, roles, userRoles } from "../db/schema.js";
|
||||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
|
||||||
function isNonEmptyString(val: unknown): val is string {
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
return typeof val === "string" && val.trim().length > 0;
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
@@ -139,6 +142,50 @@ export function registerUserAdminRoutes(
|
|||||||
: eq(users.username, resolvedUsername!),
|
: eq(users.username, resolvedUsername!),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetId = targetUser[0].id;
|
||||||
|
const adminRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, "admin"))
|
||||||
|
.limit(1);
|
||||||
|
const userRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, "user"))
|
||||||
|
.limit(1);
|
||||||
|
if (adminRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, targetId),
|
||||||
|
eq(userRoles.roleId, adminRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db.insert(userRoles).values({
|
||||||
|
userId: targetId,
|
||||||
|
roleId: adminRole[0].id,
|
||||||
|
grantedBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (userRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, targetId),
|
||||||
|
eq(userRoles.roleId, userRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (roleError) {
|
||||||
|
authLogger.error("Failed to sync admin role on make-admin", roleError, {
|
||||||
|
operation: "make_admin_role_sync",
|
||||||
|
userId: targetUser[0].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
@@ -277,6 +324,54 @@ export function registerUserAdminRoutes(
|
|||||||
: eq(users.username, resolvedUsername!),
|
: eq(users.username, resolvedUsername!),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetId = targetUser[0].id;
|
||||||
|
const adminRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, "admin"))
|
||||||
|
.limit(1);
|
||||||
|
const userRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, "user"))
|
||||||
|
.limit(1);
|
||||||
|
if (adminRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, targetId),
|
||||||
|
eq(userRoles.roleId, adminRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (userRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, targetId),
|
||||||
|
eq(userRoles.roleId, userRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db.insert(userRoles).values({
|
||||||
|
userId: targetId,
|
||||||
|
roleId: userRole[0].id,
|
||||||
|
grantedBy: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (roleError) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to sync user role on remove-admin",
|
||||||
|
roleError,
|
||||||
|
{
|
||||||
|
operation: "remove_admin_role_sync",
|
||||||
|
userId: targetUser[0].id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
@@ -321,4 +416,184 @@ export function registerUserAdminRoutes(
|
|||||||
res.status(500).json({ error: "Failed to remove admin status" });
|
res.status(500).json({ error: "Failed to remove admin status" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/admin-create:
|
||||||
|
* post:
|
||||||
|
* summary: Admin create user
|
||||||
|
* description: Allows an admin to create a new user regardless of whether public registration is enabled.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* username:
|
||||||
|
* type: string
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User created successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Username and password are required.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 409:
|
||||||
|
* description: Username already exists.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to create user.
|
||||||
|
*/
|
||||||
|
router.post("/admin-create", authenticateJWT, async (req, res) => {
|
||||||
|
const adminId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adminUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, adminId));
|
||||||
|
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to verify admin status", err);
|
||||||
|
return res.status(500).json({ error: "Failed to verify admin status" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Username and password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username));
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
const id = nanoid();
|
||||||
|
|
||||||
|
db.$client.transaction(() => {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"openid email profile",
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, "user"))
|
||||||
|
.limit(1);
|
||||||
|
if (userRole.length > 0) {
|
||||||
|
await db.insert(userRoles).values({
|
||||||
|
userId: id,
|
||||||
|
roleId: userRole[0].id,
|
||||||
|
grantedBy: adminId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (roleError) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to assign default role during admin create",
|
||||||
|
roleError,
|
||||||
|
{
|
||||||
|
operation: "admin_create_user_role",
|
||||||
|
userId: id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
try {
|
||||||
|
await authManager.registerUser(id, password);
|
||||||
|
} catch (encryptionError) {
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to setup user encryption during admin create, rolled back",
|
||||||
|
encryptionError,
|
||||||
|
{ operation: "admin_create_user_encryption_failed", userId: id },
|
||||||
|
);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Failed to setup user security - user creation cancelled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
} catch (saveError) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to persist admin-created user to disk",
|
||||||
|
saveError,
|
||||||
|
{
|
||||||
|
operation: "admin_create_user_save_failed",
|
||||||
|
userId: id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
authLogger.success("User created by admin", {
|
||||||
|
operation: "admin_create_user_success",
|
||||||
|
adminId,
|
||||||
|
userId: id,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
const adminRecord = await db
|
||||||
|
.select({ username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, adminId))
|
||||||
|
.limit(1);
|
||||||
|
await logAudit({
|
||||||
|
userId: adminId,
|
||||||
|
username: adminRecord[0]?.username ?? adminId,
|
||||||
|
action: "create_user",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: id,
|
||||||
|
resourceName: username,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "User created",
|
||||||
|
toast: { type: "success", message: `User created: ${username}` },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to admin-create user", err);
|
||||||
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ describe("isOIDCUserAllowed", () => {
|
|||||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||||
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
|
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches *@domain.com wildcard pattern against emails", () => {
|
||||||
|
expect(
|
||||||
|
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches glob patterns with multiple wildcards", () => {
|
||||||
|
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
|
||||||
|
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getOIDCConfigFromEnv", () => {
|
describe("getOIDCConfigFromEnv", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "../db/index.js";
|
|||||||
import { ssoProviders } from "../db/schema.js";
|
import { ssoProviders } from "../db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
|
import { Agent } from "undici";
|
||||||
|
|
||||||
export type OIDCConfig = {
|
export type OIDCConfig = {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
@@ -18,8 +19,14 @@ export type OIDCConfig = {
|
|||||||
allowed_users: string;
|
allowed_users: string;
|
||||||
admin_group: string;
|
admin_group: string;
|
||||||
group_claim?: string;
|
group_claim?: string;
|
||||||
|
ca_cert?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
|
||||||
|
if (!caCert || !caCert.trim()) return {};
|
||||||
|
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
|
||||||
|
}
|
||||||
|
|
||||||
export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||||
const client_id = process.env.OIDC_CLIENT_ID;
|
const client_id = process.env.OIDC_CLIENT_ID;
|
||||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||||
@@ -107,6 +114,15 @@ export function isOIDCUserAllowed(
|
|||||||
];
|
];
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
if (pattern === "*") return true;
|
if (pattern === "*") return true;
|
||||||
|
if (pattern.includes("*")) {
|
||||||
|
const escaped = pattern
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||||
|
.replace(/\*/g, ".*");
|
||||||
|
const regex = new RegExp(`^${escaped}$`);
|
||||||
|
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
if (pattern.toLowerCase().startsWith("@")) {
|
if (pattern.toLowerCase().startsWith("@")) {
|
||||||
@@ -123,7 +139,9 @@ export async function verifyOIDCToken(
|
|||||||
idToken: string,
|
idToken: string,
|
||||||
issuerUrl: string,
|
issuerUrl: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
|
caCert?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
|
const fetchOptions = buildFetchOptions(caCert);
|
||||||
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
||||||
? issuerUrl.slice(0, -1)
|
? issuerUrl.slice(0, -1)
|
||||||
: issuerUrl;
|
: issuerUrl;
|
||||||
@@ -142,7 +160,7 @@ export async function verifyOIDCToken(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||||
const discoveryResponse = await fetch(discoveryUrl);
|
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||||
if (discoveryResponse.ok) {
|
if (discoveryResponse.ok) {
|
||||||
const discovery = (await discoveryResponse.json()) as Record<
|
const discovery = (await discoveryResponse.json()) as Record<
|
||||||
string,
|
string,
|
||||||
@@ -160,7 +178,7 @@ export async function verifyOIDCToken(
|
|||||||
|
|
||||||
for (const url of jwksUrls) {
|
for (const url of jwksUrls) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, fetchOptions);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||||
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
||||||
@@ -214,6 +232,49 @@ export async function verifyOIDCToken(
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GOOGLE_DEFAULTS = {
|
||||||
|
issuer_url: "https://accounts.google.com",
|
||||||
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
token_url: "https://oauth2.googleapis.com/token",
|
||||||
|
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||||
|
identifier_path: "sub",
|
||||||
|
name_path: "name",
|
||||||
|
scopes: "openid email profile",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GITHUB_DEFAULTS = {
|
||||||
|
issuer_url: "https://token.actions.githubusercontent.com",
|
||||||
|
authorization_url: "https://github.com/login/oauth/authorize",
|
||||||
|
token_url: "https://github.com/login/oauth/access_token",
|
||||||
|
userinfo_url: "https://api.github.com/user",
|
||||||
|
identifier_path: "id",
|
||||||
|
name_path: "name",
|
||||||
|
scopes: "read:user user:email",
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyProviderDefaults(
|
||||||
|
config: OIDCConfig,
|
||||||
|
providerType: string,
|
||||||
|
): OIDCConfig {
|
||||||
|
const defaults =
|
||||||
|
providerType === "google"
|
||||||
|
? GOOGLE_DEFAULTS
|
||||||
|
: providerType === "github"
|
||||||
|
? GITHUB_DEFAULTS
|
||||||
|
: null;
|
||||||
|
if (!defaults) return config;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
issuer_url: config.issuer_url || defaults.issuer_url,
|
||||||
|
authorization_url: config.authorization_url || defaults.authorization_url,
|
||||||
|
token_url: config.token_url || defaults.token_url,
|
||||||
|
userinfo_url: config.userinfo_url || defaults.userinfo_url,
|
||||||
|
identifier_path: config.identifier_path || defaults.identifier_path,
|
||||||
|
name_path: config.name_path || defaults.name_path,
|
||||||
|
scopes: config.scopes || defaults.scopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function decryptConfigSecret(
|
function decryptConfigSecret(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
@@ -280,10 +341,14 @@ export async function loadProviderConfig(
|
|||||||
} else {
|
} else {
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = decryptConfigSecret(parsed);
|
||||||
}
|
}
|
||||||
const config = parsed as unknown as OIDCConfig;
|
const providerType = row.type as SSOProviderType;
|
||||||
|
const config = applyProviderDefaults(
|
||||||
|
parsed as unknown as OIDCConfig,
|
||||||
|
providerType,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
providerType: row.type as SSOProviderType,
|
providerType,
|
||||||
providerDbId: row.id,
|
providerDbId: row.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -318,9 +383,13 @@ export async function loadProviderConfig(
|
|||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
parsed = decryptConfigSecret(parsed);
|
parsed = decryptConfigSecret(parsed);
|
||||||
|
const oidcProviderType = oidcRow.type as SSOProviderType;
|
||||||
return {
|
return {
|
||||||
config: parsed as unknown as OIDCConfig,
|
config: applyProviderDefaults(
|
||||||
providerType: oidcRow.type as SSOProviderType,
|
parsed as unknown as OIDCConfig,
|
||||||
|
oidcProviderType,
|
||||||
|
),
|
||||||
|
providerType: oidcProviderType,
|
||||||
providerDbId: oidcRow.id,
|
providerDbId: oidcRow.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,19 @@ export function registerUserPasswordResetRoutes(
|
|||||||
*/
|
*/
|
||||||
router.post("/initiate-reset", async (req, res) => {
|
router.post("/initiate-reset", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const envVal = process.env.ALLOW_PASSWORD_RESET;
|
||||||
|
const allowed =
|
||||||
|
envVal !== undefined
|
||||||
|
? envVal.trim().toLowerCase() === "true"
|
||||||
|
: (() => {
|
||||||
const row = db.$client
|
const row = db.$client
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
|
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
|
||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
if (row && (row as { value: string }).value !== "true") {
|
return row ? (row as { value: string }).value === "true" : true;
|
||||||
|
})();
|
||||||
|
if (!allowed) {
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ error: "Password reset is currently disabled" });
|
.json({ error: "Password reset is currently disabled" });
|
||||||
@@ -346,8 +353,7 @@ export function registerUserPasswordResetRoutes(
|
|||||||
}
|
}
|
||||||
const userId = user[0].id;
|
const userId = user[0].id;
|
||||||
|
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
|
||||||
|
|
||||||
let userIdFromJwt: string | null = null;
|
let userIdFromJwt: string | null = null;
|
||||||
const cookie = req.cookies?.jwt;
|
const cookie = req.cookies?.jwt;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
|||||||
fontSize: row?.fontSize ?? null,
|
fontSize: row?.fontSize ?? null,
|
||||||
accentColor: row?.accentColor ?? null,
|
accentColor: row?.accentColor ?? null,
|
||||||
language: row?.language ?? null,
|
language: row?.language ?? null,
|
||||||
storageMode: row?.storageMode ?? "local",
|
storageMode: row?.storageMode ?? "cloud",
|
||||||
commandAutocomplete: row?.commandAutocomplete ?? null,
|
commandAutocomplete: row?.commandAutocomplete ?? null,
|
||||||
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
|
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
|
||||||
showHostTags: row?.showHostTags ?? null,
|
showHostTags: row?.showHostTags ?? null,
|
||||||
@@ -28,6 +28,8 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
|||||||
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
||||||
confirmTabClose: row?.confirmTabClose ?? null,
|
confirmTabClose: row?.confirmTabClose ?? null,
|
||||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||||
|
compactHostView: row?.compactHostView ?? null,
|
||||||
|
statusColorScheme: row?.statusColorScheme ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +94,12 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
|||||||
* hiddenRailTabs:
|
* hiddenRailTabs:
|
||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* nullable: true
|
||||||
|
* compactHostView:
|
||||||
|
* type: boolean
|
||||||
|
* nullable: true
|
||||||
|
* statusColorScheme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
*/
|
*/
|
||||||
router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -158,6 +166,10 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* hiddenRailTabs:
|
* hiddenRailTabs:
|
||||||
* type: string
|
* type: string
|
||||||
|
* compactHostView:
|
||||||
|
* type: boolean
|
||||||
|
* statusColorScheme:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Preferences updated successfully.
|
* description: Preferences updated successfully.
|
||||||
@@ -181,6 +193,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
disableUpdateCheck,
|
disableUpdateCheck,
|
||||||
confirmTabClose,
|
confirmTabClose,
|
||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
|
compactHostView,
|
||||||
|
statusColorScheme,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
reopenTabsOnLogin?: boolean;
|
reopenTabsOnLogin?: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
@@ -198,6 +212,8 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
disableUpdateCheck?: boolean | null;
|
disableUpdateCheck?: boolean | null;
|
||||||
confirmTabClose?: boolean | null;
|
confirmTabClose?: boolean | null;
|
||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
|
compactHostView?: boolean | null;
|
||||||
|
statusColorScheme?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updates: Partial<typeof userPreferences.$inferInsert> = {
|
const updates: Partial<typeof userPreferences.$inferInsert> = {
|
||||||
@@ -220,6 +236,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
language,
|
language,
|
||||||
storageMode,
|
storageMode,
|
||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
|
statusColorScheme,
|
||||||
})) {
|
})) {
|
||||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||||
return res.status(400).json({ error: `${key} must be a string` });
|
return res.status(400).json({ error: `${key} must be a string` });
|
||||||
@@ -236,6 +253,7 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
confirmSnippetExecution,
|
confirmSnippetExecution,
|
||||||
disableUpdateCheck,
|
disableUpdateCheck,
|
||||||
confirmTabClose,
|
confirmTabClose,
|
||||||
|
compactHostView,
|
||||||
};
|
};
|
||||||
for (const [key, value] of Object.entries(boolFields)) {
|
for (const [key, value] of Object.entries(boolFields)) {
|
||||||
if (value !== undefined && value !== null && typeof value !== "boolean") {
|
if (value !== undefined && value !== null && typeof value !== "boolean") {
|
||||||
@@ -263,6 +281,9 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
|||||||
if (disableUpdateCheck !== undefined)
|
if (disableUpdateCheck !== undefined)
|
||||||
updates.disableUpdateCheck = disableUpdateCheck;
|
updates.disableUpdateCheck = disableUpdateCheck;
|
||||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||||
|
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||||
|
if (statusColorScheme !== undefined)
|
||||||
|
updates.statusColorScheme = statusColorScheme;
|
||||||
|
|
||||||
if (Object.keys(updates).length === 1) {
|
if (Object.keys(updates).length === 1) {
|
||||||
return res.status(400).json({ error: "No preferences provided" });
|
return res.status(400).json({ error: "No preferences provided" });
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ function getDefaultGuacUrl(): string {
|
|||||||
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HostDefaults = {
|
||||||
|
useSocks5?: boolean;
|
||||||
|
socks5Host?: string;
|
||||||
|
socks5Port?: number;
|
||||||
|
socks5Username?: string;
|
||||||
|
socks5Password?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
|
metricsEnabled?: boolean;
|
||||||
|
statusCheckEnabled?: boolean;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
theme?: string;
|
||||||
|
cursorStyle?: string;
|
||||||
|
cursorBlink?: boolean;
|
||||||
|
enableSessionLogging?: boolean;
|
||||||
|
enableCommandHistory?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function registerUserSettingsRoutes(
|
export function registerUserSettingsRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
@@ -544,4 +562,91 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/host-defaults:
|
||||||
|
* get:
|
||||||
|
* summary: Get host creation defaults
|
||||||
|
* description: Returns the global default settings applied when creating a new host.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host defaults object.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get host defaults.
|
||||||
|
*/
|
||||||
|
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'host_defaults'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const defaults: HostDefaults = row ? JSON.parse(row.value) : {};
|
||||||
|
res.json(defaults);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get host defaults", err);
|
||||||
|
res.status(500).json({ error: "Failed to get host defaults" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/host-defaults:
|
||||||
|
* patch:
|
||||||
|
* summary: Update host creation defaults (admin only)
|
||||||
|
* description: Sets global default settings applied when a new host is created.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Host defaults updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update host defaults.
|
||||||
|
*/
|
||||||
|
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const defaults: HostDefaults = req.body;
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('host_defaults', ?)",
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(defaults));
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
const actorRecord = await db
|
||||||
|
.select({ username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actorRecord[0]?.username ?? userId,
|
||||||
|
action: "update_host_defaults",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify(defaults),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(defaults);
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update host defaults", err);
|
||||||
|
res.status(500).json({ error: "Failed to update host defaults" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import speakeasy from "speakeasy";
|
import speakeasy from "speakeasy";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||||
@@ -28,6 +29,7 @@ type TotpUserRecord = typeof users.$inferSelect;
|
|||||||
export async function verifyTotpReauth(
|
export async function verifyTotpReauth(
|
||||||
userRecord: TotpUserRecord,
|
userRecord: TotpUserRecord,
|
||||||
credential: string,
|
credential: string,
|
||||||
|
userDataKey?: Buffer | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!userRecord.isOidc && userRecord.passwordHash) {
|
if (!userRecord.isOidc && userRecord.passwordHash) {
|
||||||
const passwordMatch = await bcrypt.compare(
|
const passwordMatch = await bcrypt.compare(
|
||||||
@@ -40,8 +42,18 @@ export async function verifyTotpReauth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userRecord.totpSecret) {
|
if (userRecord.totpSecret) {
|
||||||
|
const totpSecret = userDataKey
|
||||||
|
? LazyFieldEncryption.safeGetFieldValue(
|
||||||
|
userRecord.totpSecret,
|
||||||
|
userDataKey,
|
||||||
|
userRecord.id,
|
||||||
|
"totpSecret",
|
||||||
|
)
|
||||||
|
: userRecord.totpSecret;
|
||||||
|
|
||||||
|
if (totpSecret) {
|
||||||
const totpMatch = speakeasy.totp.verify({
|
const totpMatch = speakeasy.totp.verify({
|
||||||
secret: userRecord.totpSecret,
|
secret: totpSecret,
|
||||||
encoding: "base32",
|
encoding: "base32",
|
||||||
token: credential,
|
token: credential,
|
||||||
window: 2,
|
window: 2,
|
||||||
@@ -50,12 +62,21 @@ export async function verifyTotpReauth(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBackupCodes =
|
||||||
|
userDataKey && userRecord.totpBackupCodes
|
||||||
|
? LazyFieldEncryption.safeGetFieldValue(
|
||||||
|
userRecord.totpBackupCodes,
|
||||||
|
userDataKey,
|
||||||
|
userRecord.id,
|
||||||
|
"totpBackupCodes",
|
||||||
|
)
|
||||||
|
: userRecord.totpBackupCodes;
|
||||||
|
|
||||||
let backupCodes: unknown = [];
|
let backupCodes: unknown = [];
|
||||||
try {
|
try {
|
||||||
backupCodes = userRecord.totpBackupCodes
|
backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : [];
|
||||||
? JSON.parse(userRecord.totpBackupCodes)
|
|
||||||
: [];
|
|
||||||
} catch {
|
} catch {
|
||||||
backupCodes = [];
|
backupCodes = [];
|
||||||
}
|
}
|
||||||
@@ -63,9 +84,18 @@ export async function verifyTotpReauth(
|
|||||||
const backupIndex = backupCodes.indexOf(credential);
|
const backupIndex = backupCodes.indexOf(credential);
|
||||||
if (backupIndex !== -1) {
|
if (backupIndex !== -1) {
|
||||||
backupCodes.splice(backupIndex, 1);
|
backupCodes.splice(backupIndex, 1);
|
||||||
|
const updatedJson = JSON.stringify(backupCodes);
|
||||||
|
const storedValue = userDataKey
|
||||||
|
? FieldCrypto.encryptField(
|
||||||
|
updatedJson,
|
||||||
|
userDataKey,
|
||||||
|
userRecord.id,
|
||||||
|
"totpBackupCodes",
|
||||||
|
)
|
||||||
|
: updatedJson;
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
|
.set({ totpBackupCodes: storedValue })
|
||||||
.where(eq(users.id, userRecord.id));
|
.where(eq(users.id, userRecord.id));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -172,6 +202,21 @@ export function registerUserTotpRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const passwordLoginRow = db.$client
|
||||||
|
.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'allow_password_login'",
|
||||||
|
)
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const passwordLoginAllowed = passwordLoginRow
|
||||||
|
? passwordLoginRow.value === "true"
|
||||||
|
: true;
|
||||||
|
if (!passwordLoginAllowed) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error:
|
||||||
|
"Cannot enable 2FA while password login is disabled. Enable password login first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
@@ -187,8 +232,18 @@ export function registerUserTotpRoutes(
|
|||||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
|
const totpSecret = userDataKey
|
||||||
|
? LazyFieldEncryption.safeGetFieldValue(
|
||||||
|
userRecord.totpSecret,
|
||||||
|
userDataKey,
|
||||||
|
userId,
|
||||||
|
"totpSecret",
|
||||||
|
)
|
||||||
|
: userRecord.totpSecret;
|
||||||
|
|
||||||
const verified = speakeasy.totp.verify({
|
const verified = speakeasy.totp.verify({
|
||||||
secret: userRecord.totpSecret,
|
secret: totpSecret,
|
||||||
encoding: "base32",
|
encoding: "base32",
|
||||||
token: totp_code,
|
token: totp_code,
|
||||||
window: 2,
|
window: 2,
|
||||||
@@ -202,11 +257,21 @@ export function registerUserTotpRoutes(
|
|||||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backupCodesJson = JSON.stringify(backupCodes);
|
||||||
|
const storedBackupCodes = userDataKey
|
||||||
|
? FieldCrypto.encryptField(
|
||||||
|
backupCodesJson,
|
||||||
|
userDataKey,
|
||||||
|
userId,
|
||||||
|
"totpBackupCodes",
|
||||||
|
)
|
||||||
|
: backupCodesJson;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
totpEnabled: true,
|
totpEnabled: true,
|
||||||
totpBackupCodes: JSON.stringify(backupCodes),
|
totpBackupCodes: storedBackupCodes,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
@@ -297,7 +362,12 @@ export function registerUserTotpRoutes(
|
|||||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = await verifyTotpReauth(userRecord, credential);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
|
const verified = await verifyTotpReauth(
|
||||||
|
userRecord,
|
||||||
|
credential,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
return res
|
return res
|
||||||
.status(401)
|
.status(401)
|
||||||
@@ -378,7 +448,12 @@ export function registerUserTotpRoutes(
|
|||||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = await verifyTotpReauth(userRecord, credential);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
|
const verified = await verifyTotpReauth(
|
||||||
|
userRecord,
|
||||||
|
credential,
|
||||||
|
userDataKey,
|
||||||
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
return res
|
return res
|
||||||
.status(401)
|
.status(401)
|
||||||
@@ -389,9 +464,19 @@ export function registerUserTotpRoutes(
|
|||||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backupCodesJson = JSON.stringify(backupCodes);
|
||||||
|
const storedBackupCodes = userDataKey
|
||||||
|
? FieldCrypto.encryptField(
|
||||||
|
backupCodesJson,
|
||||||
|
userDataKey,
|
||||||
|
userId,
|
||||||
|
"totpBackupCodes",
|
||||||
|
)
|
||||||
|
: backupCodesJson;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
|
.set({ totpBackupCodes: storedBackupCodes })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
res.json({ backup_codes: backupCodes });
|
res.json({ backup_codes: backupCodes });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { users, settings, roles, userRoles } from "../db/schema.js";
|
import { users, settings, roles, userRoles } from "../db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -22,9 +22,11 @@ import {
|
|||||||
verifyOIDCToken,
|
verifyOIDCToken,
|
||||||
extractOidcGroups,
|
extractOidcGroups,
|
||||||
loadProviderConfig,
|
loadProviderConfig,
|
||||||
|
buildFetchOptions,
|
||||||
} from "./user-oidc-utils.js";
|
} from "./user-oidc-utils.js";
|
||||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||||
|
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||||
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
||||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||||
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||||
@@ -43,6 +45,45 @@ function isNonEmptyString(val: unknown): val is string {
|
|||||||
return typeof val === "string" && val.trim().length > 0;
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRegistrationAllowed(): boolean {
|
||||||
|
const envVal = process.env.ALLOW_REGISTRATION;
|
||||||
|
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
return row ? row.value === "true" : true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordLoginAllowed(): boolean {
|
||||||
|
const envVal = process.env.ALLOW_PASSWORD_LOGIN;
|
||||||
|
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
return row ? row.value === "true" : true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordResetAllowed(): boolean {
|
||||||
|
const envVal = process.env.ALLOW_PASSWORD_RESET;
|
||||||
|
if (envVal !== undefined) return envVal.trim().toLowerCase() === "true";
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
return row ? row.value === "true" : true;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isNativeAppRequest(req: Request): boolean {
|
function isNativeAppRequest(req: Request): boolean {
|
||||||
return (
|
return (
|
||||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||||
@@ -85,21 +126,11 @@ const requireAdmin = authManager.createAdminMiddleware();
|
|||||||
* description: Failed to create user.
|
* description: Failed to create user.
|
||||||
*/
|
*/
|
||||||
router.post("/create", async (req, res) => {
|
router.post("/create", async (req, res) => {
|
||||||
try {
|
if (!isRegistrationAllowed()) {
|
||||||
const row = db.$client
|
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
|
||||||
.get();
|
|
||||||
if (row && (row as Record<string, unknown>).value !== "true") {
|
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ error: "Registration is currently disabled" });
|
.json({ error: "Registration is currently disabled" });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
authLogger.warn("Failed to check registration status", {
|
|
||||||
operation: "registration_check",
|
|
||||||
error: e,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
authLogger.info("User registration attempt", {
|
authLogger.info("User registration attempt", {
|
||||||
@@ -135,8 +166,7 @@ router.post("/create", async (req, res) => {
|
|||||||
return res.status(409).json({ error: "Username already exists" });
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
|
|
||||||
const isFirstUser = db.$client.transaction(() => {
|
const isFirstUser = db.$client.transaction(() => {
|
||||||
@@ -737,6 +767,9 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
.run(`oidc_provider_${state}`);
|
.run(`oidc_provider_${state}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const caCert = config.ca_cert;
|
||||||
|
const fetchOptions = buildFetchOptions(caCert);
|
||||||
|
|
||||||
// GitHub does not issue OIDC id_tokens; handle its token exchange separately
|
// GitHub does not issue OIDC id_tokens; handle its token exchange separately
|
||||||
if (callbackProviderType === "github") {
|
if (callbackProviderType === "github") {
|
||||||
const ghTokenResponse = await fetch(config.token_url, {
|
const ghTokenResponse = await fetch(config.token_url, {
|
||||||
@@ -752,6 +785,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
code: code,
|
code: code,
|
||||||
redirect_uri: backendCallbackUri,
|
redirect_uri: backendCallbackUri,
|
||||||
}),
|
}),
|
||||||
|
...fetchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!ghTokenResponse.ok) {
|
if (!ghTokenResponse.ok) {
|
||||||
@@ -789,6 +823,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent": "Termix",
|
"User-Agent": "Termix",
|
||||||
},
|
},
|
||||||
|
...fetchOptions,
|
||||||
});
|
});
|
||||||
if (!ghUserInfoResponse.ok) {
|
if (!ghUserInfoResponse.ok) {
|
||||||
return res
|
return res
|
||||||
@@ -859,17 +894,10 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
"true";
|
"true";
|
||||||
|
|
||||||
if (!isFirstUser && !ghAutoProvision) {
|
if (!isFirstUser && !ghAutoProvision) {
|
||||||
const regRow = db.$client
|
|
||||||
.prepare(
|
|
||||||
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
|
||||||
)
|
|
||||||
.get() as { value: string } | undefined;
|
|
||||||
if (regRow && regRow.value !== "true") {
|
|
||||||
const redirectUrl = new URL(frontendOrigin);
|
const redirectUrl = new URL(frontendOrigin);
|
||||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||||
return res.redirect(redirectUrl.toString());
|
return res.redirect(redirectUrl.toString());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const ghId = nanoid();
|
const ghId = nanoid();
|
||||||
const ghIsFirst = db.$client.transaction(() => {
|
const ghIsFirst = db.$client.transaction(() => {
|
||||||
@@ -972,7 +1000,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Basic ${Buffer.from(`${encodeURIComponent(config.client_id)}:${encodeURIComponent(config.client_secret)}`).toString("base64")}`,
|
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
@@ -981,6 +1008,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
code: code,
|
code: code,
|
||||||
redirect_uri: backendCallbackUri,
|
redirect_uri: backendCallbackUri,
|
||||||
}),
|
}),
|
||||||
|
...fetchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
@@ -1023,7 +1051,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||||
const discoveryResponse = await fetch(discoveryUrl);
|
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||||
if (discoveryResponse.ok) {
|
if (discoveryResponse.ok) {
|
||||||
const discovery = (await discoveryResponse.json()) as Record<
|
const discovery = (await discoveryResponse.json()) as Record<
|
||||||
string,
|
string,
|
||||||
@@ -1058,6 +1086,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
tokenData.id_token as string,
|
tokenData.id_token as string,
|
||||||
config.issuer_url,
|
config.issuer_url,
|
||||||
config.client_id,
|
config.client_id,
|
||||||
|
caCert,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
@@ -1081,6 +1110,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${tokenData.access_token}`,
|
Authorization: `Bearer ${tokenData.access_token}`,
|
||||||
},
|
},
|
||||||
|
...fetchOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userInfoResponse.ok) {
|
if (userInfoResponse.ok) {
|
||||||
@@ -1190,34 +1220,18 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isFirstUser && !oidcAutoProvision) {
|
if (!isFirstUser && !oidcAutoProvision) {
|
||||||
try {
|
|
||||||
const regRow = db.$client
|
|
||||||
.prepare(
|
|
||||||
"SELECT value FROM settings WHERE key = 'allow_registration'",
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
if (regRow && (regRow as Record<string, unknown>).value !== "true") {
|
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"OIDC user attempted to register when registration is disabled",
|
"OIDC user attempted to register but auto-provisioning is disabled",
|
||||||
{
|
{
|
||||||
operation: "oidc_registration_disabled",
|
operation: "oidc_registration_disabled",
|
||||||
identifier,
|
identifier,
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const redirectUrl = new URL(frontendOrigin);
|
const redirectUrl = new URL(frontendOrigin);
|
||||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||||
|
|
||||||
return res.redirect(redirectUrl.toString());
|
return res.redirect(redirectUrl.toString());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
authLogger.warn("Failed to check registration status during OIDC", {
|
|
||||||
operation: "oidc_registration_check",
|
|
||||||
error: e,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
isFirstUser = db.$client.transaction(() => {
|
isFirstUser = db.$client.transaction(() => {
|
||||||
@@ -1367,6 +1381,39 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
.set({ isAdmin: shouldBeAdmin })
|
.set({ isAdmin: shouldBeAdmin })
|
||||||
.where(eq(users.id, userRecord.id));
|
.where(eq(users.id, userRecord.id));
|
||||||
userRecord.isAdmin = shouldBeAdmin;
|
userRecord.isAdmin = shouldBeAdmin;
|
||||||
|
try {
|
||||||
|
const newRoleName = shouldBeAdmin ? "admin" : "user";
|
||||||
|
const oldRoleName = shouldBeAdmin ? "user" : "admin";
|
||||||
|
const newRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, newRoleName))
|
||||||
|
.limit(1);
|
||||||
|
const oldRole = await db
|
||||||
|
.select({ id: roles.id })
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.name, oldRoleName))
|
||||||
|
.limit(1);
|
||||||
|
if (oldRole.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(userRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userRoles.userId, userRecord.id),
|
||||||
|
eq(userRoles.roleId, oldRole[0].id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (newRole.length > 0) {
|
||||||
|
await db.insert(userRoles).values({
|
||||||
|
userId: userRecord.id,
|
||||||
|
roleId: newRole[0].id,
|
||||||
|
grantedBy: userRecord.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
authLogger.info("OIDC admin status synced", {
|
authLogger.info("OIDC admin status synced", {
|
||||||
operation: "oidc_admin_group_sync",
|
operation: "oidc_admin_group_sync",
|
||||||
userId: userRecord.id,
|
userId: userRecord.id,
|
||||||
@@ -1504,22 +1551,11 @@ router.post("/login", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!isPasswordLoginAllowed()) {
|
||||||
const row = db.$client
|
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
|
||||||
.get();
|
|
||||||
if (row && (row as { value: string }).value !== "true") {
|
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ error: "Password authentication is currently disabled" });
|
.json({ error: "Password authentication is currently disabled" });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
authLogger.error("Failed to check password login status", {
|
|
||||||
operation: "login_check",
|
|
||||||
error: e,
|
|
||||||
});
|
|
||||||
return res.status(500).json({ error: "Failed to check login status" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await db
|
const user = await db
|
||||||
@@ -1919,12 +1955,7 @@ router.get("/db-health", requireAdmin, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/registration-allowed", async (req, res) => {
|
router.get("/registration-allowed", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = db.$client
|
res.json({ allowed: isRegistrationAllowed() });
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
|
||||||
.get();
|
|
||||||
res.json({
|
|
||||||
allowed: row ? (row as Record<string, unknown>).value === "true" : true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get registration allowed", err);
|
authLogger.error("Failed to get registration allowed", err);
|
||||||
res.status(500).json({ error: "Failed to get registration allowed" });
|
res.status(500).json({ error: "Failed to get registration allowed" });
|
||||||
@@ -2029,6 +2060,107 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/oidc-silent-login-default:
|
||||||
|
* get:
|
||||||
|
* summary: Get OIDC silent login default setting
|
||||||
|
* description: Returns whether silent OIDC login is enabled as the default behavior.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Silent login default setting.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get setting.
|
||||||
|
*/
|
||||||
|
router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.$client
|
||||||
|
.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'oidc_silent_login_default'",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
res.json({
|
||||||
|
enabled: row ? (row as Record<string, unknown>).value === "true" : false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get OIDC silent login default", err);
|
||||||
|
res.status(500).json({ error: "Failed to get OIDC silent login default" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/oidc-silent-login-default:
|
||||||
|
* patch:
|
||||||
|
* summary: Set OIDC silent login default setting
|
||||||
|
* description: Enables or disables silent OIDC login as the default behavior on the login page.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Setting updated.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid value.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update setting.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/oidc-silent-login-default",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { enabled } = req.body;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||||
|
}
|
||||||
|
const existing = db.$client
|
||||||
|
.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'oidc_silent_login_default'",
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"UPDATE settings SET value = ? WHERE key = 'oidc_silent_login_default'",
|
||||||
|
)
|
||||||
|
.run(enabled ? "true" : "false");
|
||||||
|
} else {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('oidc_silent_login_default', ?)",
|
||||||
|
)
|
||||||
|
.run(enabled ? "true" : "false");
|
||||||
|
}
|
||||||
|
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||||
|
await saveMemoryDatabaseToFile();
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to set OIDC silent login default", err);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to set OIDC silent login default" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/password-login-allowed:
|
* /users/password-login-allowed:
|
||||||
@@ -2045,12 +2177,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/password-login-allowed", async (req, res) => {
|
router.get("/password-login-allowed", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = db.$client
|
res.json({ allowed: isPasswordLoginAllowed() });
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
|
||||||
.get();
|
|
||||||
res.json({
|
|
||||||
allowed: row ? (row as { value: string }).value === "true" : true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get password login allowed", err);
|
authLogger.error("Failed to get password login allowed", err);
|
||||||
res.status(500).json({ error: "Failed to get password login allowed" });
|
res.status(500).json({ error: "Failed to get password login allowed" });
|
||||||
@@ -2095,6 +2222,17 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
|||||||
if (typeof allowed !== "boolean") {
|
if (typeof allowed !== "boolean") {
|
||||||
return res.status(400).json({ error: "Invalid value for allowed" });
|
return res.status(400).json({ error: "Invalid value for allowed" });
|
||||||
}
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
const totpRow = db.$client
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM users WHERE totp_enabled = 1")
|
||||||
|
.get() as { count?: number };
|
||||||
|
if ((totpRow?.count || 0) > 0) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error:
|
||||||
|
"Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
db.$client
|
db.$client
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
||||||
@@ -2125,12 +2263,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/password-reset-allowed", async (req, res) => {
|
router.get("/password-reset-allowed", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = db.$client
|
res.json({ allowed: isPasswordResetAllowed() });
|
||||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
|
|
||||||
.get();
|
|
||||||
res.json({
|
|
||||||
allowed: row ? (row as { value: string }).value === "true" : true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get password reset allowed", err);
|
authLogger.error("Failed to get password reset allowed", err);
|
||||||
res.status(500).json({ error: "Failed to get password reset allowed" });
|
res.status(500).json({ error: "Failed to get password reset allowed" });
|
||||||
@@ -2347,8 +2480,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
|||||||
.json({ error: "Failed to update password and re-encrypt data." });
|
.json({ error: "Failed to update password and re-encrypt data." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ passwordHash: password_hash })
|
.set({ passwordHash: password_hash })
|
||||||
@@ -2518,6 +2650,7 @@ registerUserOidcAccountRoutes(router, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerUserSettingsRoutes(router, authenticateJWT);
|
registerUserSettingsRoutes(router, authenticateJWT);
|
||||||
|
registerAcmeSSLRoutes(router, authenticateJWT);
|
||||||
|
|
||||||
registerUserApiKeyRoutes(router, requireAdmin);
|
registerUserApiKeyRoutes(router, requireAdmin);
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { AuthManager } from "../utils/auth-manager.js";
|
|||||||
import { PermissionManager } from "../utils/permission-manager.js";
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { hosts } from "../database/db/schema.js";
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
@@ -67,9 +67,15 @@ router.use(authManager.createAuthMiddleware());
|
|||||||
*/
|
*/
|
||||||
router.post("/token", async (req, res) => {
|
router.post("/token", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type, hostname, port, username, password, domain, ...options } =
|
const { type, hostname, port, username, password, domain, ...rawOptions } =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
|
// Strip "auto" sentinel values before forwarding to guacd
|
||||||
|
const options: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(rawOptions)) {
|
||||||
|
if (value !== "auto") options[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!type || !hostname) {
|
if (!type || !hostname) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
@@ -261,12 +267,138 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip "auto" sentinel values — these mean "use guacd default" in the UI
|
||||||
|
// but guacd doesn't recognise "auto" as a valid parameter value.
|
||||||
|
for (const key of Object.keys(guacConfig)) {
|
||||||
|
if (guacConfig[key] === "auto") {
|
||||||
|
delete guacConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract per-connection guacd proxy settings before passing the rest as connection settings
|
||||||
|
const perConnectionGuacdHost = guacConfig["guacd-hostname"] as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const perConnectionGuacdPortRaw = guacConfig["guacd-port"];
|
||||||
|
const perConnectionGuacdPort = perConnectionGuacdPortRaw
|
||||||
|
? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined
|
||||||
|
: undefined;
|
||||||
|
delete guacConfig["guacd-hostname"];
|
||||||
|
delete guacConfig["guacd-port"];
|
||||||
|
|
||||||
if (guacConfig.dpi != null) {
|
if (guacConfig.dpi != null) {
|
||||||
const parsed = parseInt(String(guacConfig.dpi), 10);
|
const parsed = parseInt(String(guacConfig.dpi), 10);
|
||||||
guacConfig.dpi =
|
guacConfig.dpi =
|
||||||
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostRecord = host as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Backward compat: if authType is not stored but a credentialId is, treat as credential mode
|
||||||
|
const rdpEffectiveAuthType =
|
||||||
|
(host.rdpAuthType as string) ||
|
||||||
|
(host.rdpCredentialId ? "credential" : "direct");
|
||||||
|
const vncEffectiveAuthType =
|
||||||
|
(host.vncAuthType as string) ||
|
||||||
|
(host.vncCredentialId ? "credential" : "direct");
|
||||||
|
const telnetEffectiveAuthType =
|
||||||
|
(host.telnetAuthType as string) ||
|
||||||
|
(hostRecord.telnetCredentialId ? "credential" : "direct");
|
||||||
|
|
||||||
|
if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) {
|
||||||
|
try {
|
||||||
|
const rdpCreds = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, host.rdpCredentialId as number),
|
||||||
|
eq(sshCredentials.userId, host.userId as string),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (rdpCreds.length > 0) {
|
||||||
|
const cred = rdpCreds[0] as Record<string, unknown>;
|
||||||
|
if (cred.username) host.rdpUser = cred.username;
|
||||||
|
if (cred.password) host.rdpPassword = cred.password;
|
||||||
|
// domain is never sourced from credential
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve RDP credential", {
|
||||||
|
operation: "guac_rdp_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vncEffectiveAuthType === "credential" && host.vncCredentialId) {
|
||||||
|
try {
|
||||||
|
const vncCreds = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, host.vncCredentialId as number),
|
||||||
|
eq(sshCredentials.userId, host.userId as string),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (vncCreds.length > 0) {
|
||||||
|
const cred = vncCreds[0] as Record<string, unknown>;
|
||||||
|
if (cred.password) host.vncPassword = cred.password;
|
||||||
|
if (cred.username) host.vncUser = cred.username;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve VNC credential", {
|
||||||
|
operation: "guac_vnc_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
telnetEffectiveAuthType === "credential" &&
|
||||||
|
hostRecord.telnetCredentialId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const telnetCreds = await SimpleDBOps.select(
|
||||||
|
getDb()
|
||||||
|
.select()
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
sshCredentials.id,
|
||||||
|
hostRecord.telnetCredentialId as number,
|
||||||
|
),
|
||||||
|
eq(sshCredentials.userId, host.userId as string),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ssh_credentials",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
if (telnetCreds.length > 0) {
|
||||||
|
const cred = telnetCreds[0] as Record<string, unknown>;
|
||||||
|
if (cred.username) host.telnetUser = cred.username;
|
||||||
|
if (cred.password) host.telnetPassword = cred.password;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
guacLogger.warn("Failed to resolve Telnet credential", {
|
||||||
|
operation: "guac_telnet_credential_resolve",
|
||||||
|
hostId,
|
||||||
|
error: e instanceof Error ? e.message : "Unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let token: string;
|
let token: string;
|
||||||
let hostname = host.ip as string;
|
let hostname = host.ip as string;
|
||||||
let port = host.port as number;
|
let port = host.port as number;
|
||||||
@@ -385,6 +517,15 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const guacdOverrides = {
|
||||||
|
...(perConnectionGuacdHost
|
||||||
|
? { guacdHost: perConnectionGuacdHost }
|
||||||
|
: {}),
|
||||||
|
...(perConnectionGuacdPort
|
||||||
|
? { guacdPort: perConnectionGuacdPort }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
switch (connectionType) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||||
@@ -405,6 +546,7 @@ router.post(
|
|||||||
? !!host.ignoreCert
|
? !!host.ignoreCert
|
||||||
: true,
|
: true,
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
@@ -416,6 +558,7 @@ router.post(
|
|||||||
port,
|
port,
|
||||||
security: "any",
|
security: "any",
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -423,6 +566,7 @@ router.post(
|
|||||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||||
port,
|
port,
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { guacLogger } from "../utils/logger.js";
|
|||||||
|
|
||||||
export interface GuacamoleConnectionSettings {
|
export interface GuacamoleConnectionSettings {
|
||||||
type: "rdp" | "vnc" | "telnet";
|
type: "rdp" | "vnc" | "telnet";
|
||||||
|
guacdHost?: string;
|
||||||
|
guacdPort?: number;
|
||||||
settings: {
|
settings: {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -120,18 +122,24 @@ export class GuacamoleTokenService {
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||||
|
guacdHost?: string;
|
||||||
|
guacdPort?: number;
|
||||||
|
} = {},
|
||||||
): string {
|
): string {
|
||||||
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
connection: {
|
connection: {
|
||||||
type: "rdp",
|
type: "rdp",
|
||||||
|
...(guacdHost ? { guacdHost } : {}),
|
||||||
|
...(guacdPort ? { guacdPort } : {}),
|
||||||
settings: {
|
settings: {
|
||||||
hostname,
|
hostname,
|
||||||
username,
|
...(username ? { username } : {}),
|
||||||
password,
|
...(password ? { password } : {}),
|
||||||
port: 3389,
|
port: 3389,
|
||||||
"ignore-cert": true,
|
"ignore-cert": true,
|
||||||
...options,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -142,17 +150,23 @@ export class GuacamoleTokenService {
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||||
|
guacdHost?: string;
|
||||||
|
guacdPort?: number;
|
||||||
|
} = {},
|
||||||
): string {
|
): string {
|
||||||
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
connection: {
|
connection: {
|
||||||
type: "vnc",
|
type: "vnc",
|
||||||
|
...(guacdHost ? { guacdHost } : {}),
|
||||||
|
...(guacdPort ? { guacdPort } : {}),
|
||||||
settings: {
|
settings: {
|
||||||
hostname,
|
hostname,
|
||||||
...(username ? { username } : {}),
|
...(username ? { username } : {}),
|
||||||
password,
|
password,
|
||||||
port: 5900,
|
port: 5900,
|
||||||
...options,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -163,17 +177,23 @@ export class GuacamoleTokenService {
|
|||||||
hostname: string,
|
hostname: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string,
|
||||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||||
|
guacdHost?: string;
|
||||||
|
guacdPort?: number;
|
||||||
|
} = {},
|
||||||
): string {
|
): string {
|
||||||
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
connection: {
|
connection: {
|
||||||
type: "telnet",
|
type: "telnet",
|
||||||
|
...(guacdHost ? { guacdHost } : {}),
|
||||||
|
...(guacdPort ? { guacdPort } : {}),
|
||||||
settings: {
|
settings: {
|
||||||
hostname,
|
hostname,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
port: 23,
|
port: 23,
|
||||||
...options,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface HostConfig {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
authType?: string;
|
authType?: string;
|
||||||
|
useWarpgate?: boolean;
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
forceKeyboardInteractive?: boolean;
|
forceKeyboardInteractive?: boolean;
|
||||||
@@ -112,6 +113,7 @@ export class SSHAuthManager {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
finish: (responses: string[]) => void,
|
||||||
resolvedCredentials: ResolvedCredentials,
|
resolvedCredentials: ResolvedCredentials,
|
||||||
|
hostConfig?: HostConfig,
|
||||||
): void {
|
): void {
|
||||||
this.context.isKeyboardInteractive = true;
|
this.context.isKeyboardInteractive = true;
|
||||||
const promptTexts = prompts.map((p) => p.prompt);
|
const promptTexts = prompts.map((p) => p.prompt);
|
||||||
@@ -143,7 +145,7 @@ export class SSHAuthManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials);
|
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleWarpgateAuth(
|
private handleWarpgateAuth(
|
||||||
@@ -282,7 +284,22 @@ export class SSHAuthManager {
|
|||||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||||
finish: (responses: string[]) => void,
|
finish: (responses: string[]) => void,
|
||||||
resolvedCredentials: ResolvedCredentials,
|
resolvedCredentials: ResolvedCredentials,
|
||||||
|
hostConfig?: HostConfig,
|
||||||
): void {
|
): void {
|
||||||
|
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
|
||||||
|
// Warpgate sends a password prompt before its browser-verification round; we must
|
||||||
|
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
|
||||||
|
if (hostConfig?.useWarpgate) {
|
||||||
|
const responses = prompts.map((p) => {
|
||||||
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
|
return resolvedCredentials.password as string;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
finish(responses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasStoredPassword =
|
const hasStoredPassword =
|
||||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||||
|
|
||||||
@@ -290,19 +307,34 @@ export class SSHAuthManager {
|
|||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
|
||||||
|
// that don't say "password" (e.g. "Passcode or option (1-N):").
|
||||||
|
const firstUnansweredIndex = prompts.findIndex((p) => {
|
||||||
|
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (firstUnansweredIndex !== -1) {
|
||||||
if (this.context.keyboardInteractiveResponded) {
|
if (this.context.keyboardInteractiveResponded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.context.keyboardInteractiveResponded = true;
|
this.context.keyboardInteractiveResponded = true;
|
||||||
|
|
||||||
|
const promptIndex =
|
||||||
|
passwordPromptIndex !== -1 && !hasStoredPassword
|
||||||
|
? passwordPromptIndex
|
||||||
|
: firstUnansweredIndex;
|
||||||
|
|
||||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||||
const userInput = (userResponses[0] || "").trim();
|
const userInput = (userResponses[0] || "").trim();
|
||||||
|
|
||||||
const responses = prompts.map((p, index) => {
|
const responses = prompts.map((p, index) => {
|
||||||
if (index === passwordPromptIndex) {
|
if (index === promptIndex) {
|
||||||
return userInput;
|
return userInput;
|
||||||
}
|
}
|
||||||
|
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||||
|
return resolvedCredentials.password;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ export class SSHAuthManager {
|
|||||||
this.context.ws.send(
|
this.context.ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "password_required",
|
type: "password_required",
|
||||||
prompt: prompts[passwordPromptIndex].prompt,
|
prompt: prompts[promptIndex].prompt,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { pickResolvedUsername } from "./credential-username.js";
|
import {
|
||||||
|
pickResolvedUsername,
|
||||||
|
expandOidcUsername,
|
||||||
|
} from "./credential-username.js";
|
||||||
|
|
||||||
describe("pickResolvedUsername", () => {
|
describe("pickResolvedUsername", () => {
|
||||||
it("keeps the host username when one is set, even with a credential username", () => {
|
it("keeps the host username when one is set, even with a credential username", () => {
|
||||||
@@ -26,3 +29,70 @@ describe("pickResolvedUsername", () => {
|
|||||||
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
|
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("expandOidcUsername", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the username unchanged when it has no placeholder", async () => {
|
||||||
|
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
|
||||||
|
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||||
|
vi.doMock("../database/db/index.js", () => ({
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ oidcIdentifier: "jdoe" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||||
|
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||||
|
|
||||||
|
const { expandOidcUsername: expand } =
|
||||||
|
await import("./credential-username.js");
|
||||||
|
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||||
|
vi.doMock("../database/db/index.js", () => ({
|
||||||
|
getDb: () => ({
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () => [{ oidcIdentifier: null }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||||
|
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||||
|
|
||||||
|
const { expandOidcUsername: expand } =
|
||||||
|
await import("./credential-username.js");
|
||||||
|
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||||
|
"$oidc.preferred_username",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the username unchanged when the DB lookup throws", async () => {
|
||||||
|
vi.doMock("../database/db/index.js", () => ({
|
||||||
|
getDb: () => {
|
||||||
|
throw new Error("DB unavailable");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { expandOidcUsername: expand } =
|
||||||
|
await import("./credential-username.js");
|
||||||
|
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||||
|
"$oidc.preferred_username",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,40 @@ export function pickResolvedUsername(
|
|||||||
return cred;
|
return cred;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||||
|
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||||
|
* not contain the placeholder or the user has no OIDC identifier.
|
||||||
|
*/
|
||||||
|
export async function expandOidcUsername(
|
||||||
|
username: string | undefined,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!username || !username.includes("$oidc.preferred_username")) {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getDb } = await import("../database/db/index.js");
|
||||||
|
const { users } = await import("../database/db/schema.js");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = await db
|
||||||
|
.select({ oidcIdentifier: users.oidcIdentifier })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const oidcIdentifier = rows[0]?.oidcIdentifier;
|
||||||
|
if (!oidcIdentifier) return username;
|
||||||
|
|
||||||
|
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||||
|
} catch {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): value is string {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.trim() !== "";
|
return typeof value === "string" && value.trim() !== "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type DockerSession = {
|
|||||||
lastActive: number;
|
lastActive: number;
|
||||||
activeOperations: number;
|
activeOperations: number;
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
|
isWindows?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingDockerTotpSession = unknown;
|
type PendingDockerTotpSession = unknown;
|
||||||
@@ -93,7 +94,10 @@ export function registerDockerContainerRoutes(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const allFlag = all ? "-a " : "";
|
const allFlag = all ? "-a " : "";
|
||||||
const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
|
const formatStr = session.isWindows
|
||||||
|
? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`
|
||||||
|
: `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
||||||
|
const command = `docker ps ${allFlag}--format ${formatStr}`;
|
||||||
|
|
||||||
const output = await executeDockerCommand(
|
const output = await executeDockerCommand(
|
||||||
session,
|
session,
|
||||||
@@ -916,7 +920,7 @@ export function registerDockerContainerRoutes(
|
|||||||
session.activeOperations++;
|
session.activeOperations++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let command = `docker logs ${containerId} 2>&1`;
|
let command = `docker logs ${containerId}`;
|
||||||
|
|
||||||
if (tail && tail > 0) {
|
if (tail && tail > 0) {
|
||||||
command += ` --tail ${Math.floor(tail)}`;
|
command += ` --tail ${Math.floor(tail)}`;
|
||||||
@@ -934,6 +938,8 @@ export function registerDockerContainerRoutes(
|
|||||||
command += ` --until ${until}`;
|
command += ` --until ${until}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
command += " 2>&1";
|
||||||
|
|
||||||
const logs = await executeDockerCommand(
|
const logs = await executeDockerCommand(
|
||||||
session,
|
session,
|
||||||
command,
|
command,
|
||||||
@@ -1026,7 +1032,10 @@ export function registerDockerContainerRoutes(
|
|||||||
session.activeOperations++;
|
session.activeOperations++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
|
const statsFormatStr = session.isWindows
|
||||||
|
? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`
|
||||||
|
: `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
||||||
|
const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`;
|
||||||
|
|
||||||
const output = await executeDockerCommand(
|
const output = await executeDockerCommand(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface SSHSession {
|
|||||||
activeOperations: number;
|
activeOperations: number;
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
isWindows?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingTOTPSession {
|
interface PendingTOTPSession {
|
||||||
@@ -852,9 +853,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
resolvedCredentials.authType === "none" ||
|
resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale"
|
resolvedCredentials.authType === "tailscale" ||
|
||||||
|
resolvedCredentials.authType === "warpgate"
|
||||||
) {
|
) {
|
||||||
// Tailscale SSH and "none" auth: no credentials needed
|
// Tailscale SSH, "none", and Warpgate auth: no static credentials
|
||||||
} else if (resolvedCredentials.authType === "password") {
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
if (resolvedCredentials.password) {
|
if (resolvedCredentials.password) {
|
||||||
config.password = resolvedCredentials.password;
|
config.password = resolvedCredentials.password;
|
||||||
@@ -1038,7 +1040,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
sshSessions[sessionId] = {
|
const session: SSHSession = {
|
||||||
client,
|
client,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
lastActive: Date.now(),
|
lastActive: Date.now(),
|
||||||
@@ -1047,8 +1049,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
userId,
|
userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
sshSessions[sessionId] = session;
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
client.exec("ver", (err, stream) => {
|
||||||
|
if (!err && stream) {
|
||||||
|
let output = "";
|
||||||
|
stream.on("data", (d: Buffer) => {
|
||||||
|
output += d.toString();
|
||||||
|
});
|
||||||
|
stream.on("close", () => {
|
||||||
|
if (output.toLowerCase().includes("windows")) {
|
||||||
|
session.isWindows = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.stderr.on("data", () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "SSH connection established",
|
message: "SSH connection established",
|
||||||
@@ -1313,6 +1331,11 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (resolvedCredentials.authType === "warpgate") {
|
||||||
|
finish(prompts.map(() => ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(resolvedCredentials.authType === "none" ||
|
(resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale") &&
|
resolvedCredentials.authType === "tailscale") &&
|
||||||
@@ -2144,7 +2167,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await executeDockerCommand(
|
await executeDockerCommand(
|
||||||
session,
|
session,
|
||||||
"docker ps >/dev/null 2>&1",
|
"docker ps",
|
||||||
sessionId,
|
sessionId,
|
||||||
userId,
|
userId,
|
||||||
session.hostId,
|
session.hostId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Express } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
|
import Busboy from "busboy";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
import { fileLogger } from "../utils/logger.js";
|
import { fileLogger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -1302,4 +1303,208 @@ export function registerFileContentRoutes(
|
|||||||
|
|
||||||
trySFTP();
|
trySFTP();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/file_manager/ssh/uploadFileStream:
|
||||||
|
* post:
|
||||||
|
* summary: Stream-upload a file via multipart form
|
||||||
|
* description: Uploads a file to the remote host by streaming multipart form data directly into an SFTP write stream, avoiding full in-memory buffering.
|
||||||
|
* tags:
|
||||||
|
* - File Manager
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* multipart/form-data:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - sessionId
|
||||||
|
* - path
|
||||||
|
* - file
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* path:
|
||||||
|
* type: string
|
||||||
|
* file:
|
||||||
|
* type: string
|
||||||
|
* format: binary
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: File uploaded successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Missing required parameters or SSH connection not established.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to upload file.
|
||||||
|
*/
|
||||||
|
app.post(
|
||||||
|
"/ssh/file_manager/ssh/uploadFileStream",
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
const contentType = req.headers["content-type"] || "";
|
||||||
|
if (!contentType.includes("multipart/form-data")) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Expected multipart/form-data request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
let remotePath: string | undefined;
|
||||||
|
let fileName: string | undefined;
|
||||||
|
let uploadStartTime: number;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const bb = Busboy({ headers: req.headers });
|
||||||
|
|
||||||
|
bb.on("field", (name: string, value: string) => {
|
||||||
|
if (name === "sessionId") sessionId = value;
|
||||||
|
if (name === "path") remotePath = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
bb.on(
|
||||||
|
"file",
|
||||||
|
(
|
||||||
|
fieldname: string,
|
||||||
|
fileStream: NodeJS.ReadableStream,
|
||||||
|
info: { filename: string; encoding: string; mimeType: string },
|
||||||
|
) => {
|
||||||
|
fileName = info.filename;
|
||||||
|
|
||||||
|
if (!sessionId || !remotePath || !fileName) {
|
||||||
|
fileStream.resume();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing sessionId or path field" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshConn = sshSessions[sessionId];
|
||||||
|
if (!sshConn?.isConnected) {
|
||||||
|
fileStream.resume();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res.status(400).json({ error: "SSH connection not established" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifySessionOwnership(sshConn, userId)) {
|
||||||
|
fileStream.resume();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res.status(403).json({ error: "Session access denied" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn.lastActive = Date.now();
|
||||||
|
uploadStartTime = Date.now();
|
||||||
|
|
||||||
|
const fullPath = remotePath.endsWith("/")
|
||||||
|
? remotePath + fileName
|
||||||
|
: remotePath + "/" + fileName;
|
||||||
|
|
||||||
|
fileLogger.info("Streaming file upload started", {
|
||||||
|
operation: "file_upload_stream_start",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: fullPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
getSessionSftp(sshConn)
|
||||||
|
.then((sftp) => {
|
||||||
|
const writeStream = sftp.createWriteStream(fullPath);
|
||||||
|
|
||||||
|
writeStream.on("error", (err) => {
|
||||||
|
fileLogger.error("SFTP write stream error during upload:", err);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Upload failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on("finish", () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
fileLogger.success("Streaming file upload completed", {
|
||||||
|
operation: "file_upload_stream_complete",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: fullPath,
|
||||||
|
duration: Date.now() - uploadStartTime,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
message: "File uploaded successfully",
|
||||||
|
path: fullPath,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `File uploaded: ${fullPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on("close", () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
fileLogger.success("Streaming file upload completed", {
|
||||||
|
operation: "file_upload_stream_complete",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: fullPath,
|
||||||
|
duration: Date.now() - uploadStartTime,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
message: "File uploaded successfully",
|
||||||
|
path: fullPath,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `File uploaded: ${fullPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fileStream.on("error", (err) => {
|
||||||
|
fileLogger.error("File read stream error during upload:", err);
|
||||||
|
writeStream.destroy();
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Upload stream error: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(fileStream as NodeJS.ReadableStream).pipe(
|
||||||
|
writeStream as unknown as NodeJS.WritableStream,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
fileStream.resume();
|
||||||
|
fileLogger.error("SFTP session error during stream upload:", err);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res.status(500).json({ error: `SFTP error: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
bb.on("error", (err: Error) => {
|
||||||
|
fileLogger.error("Busboy parse error during stream upload:", err);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
res.status(500).json({ error: `Upload parse error: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.pipe(bb);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { Express } from "express";
|
|||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
import { fileLogger } from "../utils/logger.js";
|
import { fileLogger } from "../utils/logger.js";
|
||||||
import { getMimeType } from "./file-manager-utils.js";
|
import { getMimeType } from "./file-manager-utils.js";
|
||||||
import { getSessionSftp, type SSHSession } from "./file-manager-session.js";
|
import {
|
||||||
|
execChannel,
|
||||||
|
getSessionSftp,
|
||||||
|
type SSHSession,
|
||||||
|
} from "./file-manager-session.js";
|
||||||
|
|
||||||
type FileDownloadRoutesDeps = {
|
type FileDownloadRoutesDeps = {
|
||||||
sshSessions: Record<string, SSHSession>;
|
sshSessions: Record<string, SSHSession>;
|
||||||
@@ -10,6 +14,37 @@ type FileDownloadRoutesDeps = {
|
|||||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function escapeSingleQuotes(path: string): string {
|
||||||
|
return path.replace(/'/g, "'\"'\"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadViaExec(
|
||||||
|
session: SSHSession,
|
||||||
|
filePath: string,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const escaped = escapeSingleQuotes(filePath);
|
||||||
|
execChannel(session, `cat '${escaped}'`, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let stderr = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
stream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
return reject(
|
||||||
|
new Error(stderr.trim() || `cat exited with code ${code}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerFileDownloadRoutes(
|
export function registerFileDownloadRoutes(
|
||||||
app: Express,
|
app: Express,
|
||||||
{
|
{
|
||||||
@@ -23,7 +58,7 @@ export function registerFileDownloadRoutes(
|
|||||||
* /ssh/file_manager/ssh/downloadFile:
|
* /ssh/file_manager/ssh/downloadFile:
|
||||||
* post:
|
* post:
|
||||||
* summary: Download a file
|
* summary: Download a file
|
||||||
* description: Downloads a file from the remote host.
|
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||||
* tags:
|
* tags:
|
||||||
* - File Manager
|
* - File Manager
|
||||||
* requestBody:
|
* requestBody:
|
||||||
@@ -88,6 +123,45 @@ export function registerFileDownloadRoutes(
|
|||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
|
|
||||||
|
if (sshConn.scpLegacy) {
|
||||||
|
fileLogger.info(
|
||||||
|
"Downloading file via legacy exec/cat (SCP legacy mode)",
|
||||||
|
{
|
||||||
|
operation: "file_download_legacy",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: filePath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await downloadViaExec(sshConn, filePath);
|
||||||
|
const fileName = filePath.split("/").pop() || "download";
|
||||||
|
fileLogger.success("File download completed (legacy mode)", {
|
||||||
|
operation: "file_download_complete",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
path: filePath,
|
||||||
|
bytes: data.length,
|
||||||
|
duration: Date.now() - downloadStartTime,
|
||||||
|
});
|
||||||
|
return res.json({
|
||||||
|
content: data.toString("base64"),
|
||||||
|
fileName,
|
||||||
|
size: data.length,
|
||||||
|
mimeType: getMimeType(fileName),
|
||||||
|
path: filePath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
fileLogger.error("Legacy exec/cat download failed:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: `Failed to download file: ${(err as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileLogger.info("Opening SFTP channel", {
|
fileLogger.info("Opening SFTP channel", {
|
||||||
operation: "file_sftp_open",
|
operation: "file_sftp_open",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -168,6 +242,33 @@ export function registerFileDownloadRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/file_manager/ssh/downloadFileStream:
|
||||||
|
* post:
|
||||||
|
* summary: Stream-download a file
|
||||||
|
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||||
|
* tags:
|
||||||
|
* - File Manager
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* sessionId:
|
||||||
|
* type: string
|
||||||
|
* path:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Binary file stream.
|
||||||
|
* 400:
|
||||||
|
* description: Missing required parameters.
|
||||||
|
* 500:
|
||||||
|
* description: Download failed.
|
||||||
|
*/
|
||||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||||
const { sessionId, path: filePath } = req.body;
|
const { sessionId, path: filePath } = req.body;
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -188,6 +289,43 @@ export function registerFileDownloadRoutes(
|
|||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
|
const fileName = filePath.split("/").pop() || "download";
|
||||||
|
res.setHeader("Content-Type", "application/octet-stream");
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sshConn.scpLegacy) {
|
||||||
|
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
|
||||||
|
operation: "file_download_stream_legacy",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const escaped = escapeSingleQuotes(filePath);
|
||||||
|
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream.on("error", (streamErr: Error) => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: `Download failed: ${streamErr.message}` });
|
||||||
|
} else {
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.pipe(res);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sftp = await getSessionSftp(sshConn);
|
const sftp = await getSessionSftp(sshConn);
|
||||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||||
@@ -200,12 +338,6 @@ export function registerFileDownloadRoutes(
|
|||||||
return res.status(400).json({ error: "Cannot download directories" });
|
return res.status(400).json({ error: "Cannot download directories" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = filePath.split("/").pop() || "download";
|
|
||||||
res.setHeader("Content-Type", "application/octet-stream");
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
||||||
);
|
|
||||||
res.setHeader("Content-Length", String(stats.size));
|
res.setHeader("Content-Length", String(stats.size));
|
||||||
|
|
||||||
const readStream = sftp.createReadStream(filePath);
|
const readStream = sftp.createReadStream(filePath);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
execWithSudo,
|
execWithSudo,
|
||||||
type SSHSession,
|
type SSHSession,
|
||||||
} from "./file-manager-session.js";
|
} from "./file-manager-session.js";
|
||||||
|
import { isWindowsSftpPath } from "./transfer-paths.js";
|
||||||
|
|
||||||
type FileOperationRoutesDeps = {
|
type FileOperationRoutesDeps = {
|
||||||
sshSessions: Record<string, SSHSession>;
|
sshSessions: Record<string, SSHSession>;
|
||||||
@@ -373,11 +374,23 @@ export function registerFileOperationRoutes(
|
|||||||
type: isDirectory ? "directory" : "file",
|
type: isDirectory ? "directory" : "file",
|
||||||
});
|
});
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
|
||||||
|
|
||||||
const deleteCommand = isDirectory
|
const isWindowsPath = isWindowsSftpPath(itemPath);
|
||||||
|
let deleteCommand: string;
|
||||||
|
if (isWindowsPath) {
|
||||||
|
const winPath = itemPath
|
||||||
|
.replace(/\//g, "\\")
|
||||||
|
.replace(/^\\([A-Za-z]:\\)/, "$1");
|
||||||
|
const escapedWinPath = winPath.replace(/"/g, '""');
|
||||||
|
deleteCommand = isDirectory
|
||||||
|
? `rd /s /q "${escapedWinPath}"`
|
||||||
|
: `del /f /q "${escapedWinPath}"`;
|
||||||
|
} else {
|
||||||
|
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||||
|
deleteCommand = isDirectory
|
||||||
? `rm -rf '${escapedPath}'`
|
? `rm -rf '${escapedPath}'`
|
||||||
: `rm -f '${escapedPath}'`;
|
: `rm -f '${escapedPath}'`;
|
||||||
|
}
|
||||||
|
|
||||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -465,10 +478,6 @@ export function registerFileOperationRoutes(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// The shell didn't echo our SUCCESS sentinel. This happens on
|
|
||||||
// non-POSIX shells (e.g. Windows PowerShell, where `rm -f` is
|
|
||||||
// not supported) and the command can exit 0 while doing nothing.
|
|
||||||
// Surface whatever the shell produced so the failure isn't silent.
|
|
||||||
const detail =
|
const detail =
|
||||||
errorData.trim() ||
|
errorData.trim() ||
|
||||||
outputData.trim() ||
|
outputData.trim() ||
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface SSHSession {
|
|||||||
transferDedicated?: boolean;
|
transferDedicated?: boolean;
|
||||||
transferId?: string;
|
transferId?: string;
|
||||||
browseSessionId?: string;
|
browseSessionId?: string;
|
||||||
|
scpLegacy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingTOTPSession {
|
export interface PendingTOTPSession {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ async function buildDedicatedTransferConnectConfig(
|
|||||||
client: SSHClient,
|
client: SSHClient,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const { ip, port, username } = host;
|
const { ip, port, username } = host;
|
||||||
|
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id);
|
||||||
const config: Record<string, unknown> = {
|
const config: Record<string, unknown> = {
|
||||||
host: ip?.replace(/^\[|\]$/g, "") || ip,
|
host: ip?.replace(/^\[|\]$/g, "") || ip,
|
||||||
port,
|
port,
|
||||||
@@ -149,6 +150,7 @@ async function buildDedicatedTransferConnectConfig(
|
|||||||
null,
|
null,
|
||||||
userId,
|
userId,
|
||||||
false,
|
false,
|
||||||
|
preloadedHostData,
|
||||||
),
|
),
|
||||||
env: {
|
env: {
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
@@ -726,6 +728,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
let resolvedPort = port;
|
let resolvedPort = port;
|
||||||
let resolvedUsername = username;
|
let resolvedUsername = username;
|
||||||
let resolvedJumpHosts = jumpHosts;
|
let resolvedJumpHosts = jumpHosts;
|
||||||
|
let resolvedScpLegacy = false;
|
||||||
|
let resolvedUseSocks5 = useSocks5;
|
||||||
|
let resolvedSocks5Host = socks5Host;
|
||||||
|
let resolvedSocks5Port = socks5Port;
|
||||||
|
let resolvedSocks5Username = socks5Username;
|
||||||
|
let resolvedSocks5Password = socks5Password;
|
||||||
|
let resolvedSocks5ProxyChain = socks5ProxyChain;
|
||||||
if (hostId && userId && !password && !sshKey) {
|
if (hostId && userId && !password && !sshKey) {
|
||||||
try {
|
try {
|
||||||
const { resolveHostById } = await import("./host-resolver.js");
|
const { resolveHostById } = await import("./host-resolver.js");
|
||||||
@@ -743,6 +752,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
};
|
};
|
||||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||||
|
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||||
|
if (resolvedHost.useSocks5) {
|
||||||
|
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||||
|
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||||
|
resolvedSocks5Port = resolvedHost.socks5Port;
|
||||||
|
resolvedSocks5Username = resolvedHost.socks5Username;
|
||||||
|
resolvedSocks5Password = resolvedHost.socks5Password;
|
||||||
|
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||||
resolvedHost.jumpHosts &&
|
resolvedHost.jumpHosts &&
|
||||||
@@ -790,6 +808,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
};
|
};
|
||||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||||
|
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||||
|
if (resolvedHost.useSocks5) {
|
||||||
|
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||||
|
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||||
|
resolvedSocks5Port = resolvedHost.socks5Port;
|
||||||
|
resolvedSocks5Username = resolvedHost.socks5Username;
|
||||||
|
resolvedSocks5Password = resolvedHost.socks5Password;
|
||||||
|
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||||
resolvedHost.jumpHosts &&
|
resolvedHost.jumpHosts &&
|
||||||
@@ -822,6 +849,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId);
|
||||||
const config: Record<string, unknown> = {
|
const config: Record<string, unknown> = {
|
||||||
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
|
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
|
||||||
port: resolvedPort,
|
port: resolvedPort,
|
||||||
@@ -829,10 +857,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
tryKeyboard: true,
|
tryKeyboard: true,
|
||||||
keepaliveInterval:
|
keepaliveInterval:
|
||||||
typeof hostKeepaliveInterval === "number"
|
typeof hostKeepaliveInterval === "number"
|
||||||
? hostKeepaliveInterval * 1000
|
? Math.max(5000, hostKeepaliveInterval * 1000)
|
||||||
: 60000,
|
: 60000,
|
||||||
keepaliveCountMax:
|
keepaliveCountMax:
|
||||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
typeof hostKeepaliveCountMax === "number"
|
||||||
|
? Math.max(1, hostKeepaliveCountMax)
|
||||||
|
: 5,
|
||||||
readyTimeout: 60000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
@@ -843,6 +873,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
null,
|
null,
|
||||||
userId,
|
userId,
|
||||||
false,
|
false,
|
||||||
|
preloadedHostData,
|
||||||
),
|
),
|
||||||
env: {
|
env: {
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
@@ -1015,7 +1046,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
resolvedCredentials.authType === "none" ||
|
resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale"
|
resolvedCredentials.authType === "tailscale" ||
|
||||||
|
resolvedCredentials.authType === "warpgate"
|
||||||
) {
|
) {
|
||||||
connectionLogs.push(
|
connectionLogs.push(
|
||||||
createConnectionLog(
|
createConnectionLog(
|
||||||
@@ -1109,6 +1141,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
hostId,
|
hostId,
|
||||||
username,
|
username,
|
||||||
sudoPassword: resolvedCredentials.sudoPassword,
|
sudoPassword: resolvedCredentials.sudoPassword,
|
||||||
|
scpLegacy: resolvedScpLegacy,
|
||||||
};
|
};
|
||||||
scheduleSessionCleanup(sessionId);
|
scheduleSessionCleanup(sessionId);
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1266,6 +1299,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
err.message.includes("Cannot parse privateKey") &&
|
||||||
|
err.message.includes("no passphrase")
|
||||||
|
) {
|
||||||
|
res.json({
|
||||||
|
status: "passphrase_required",
|
||||||
|
connectionLogs,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
(resolvedCredentials.authType === "none" ||
|
(resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale") &&
|
resolvedCredentials.authType === "tailscale") &&
|
||||||
(err.message.includes("authentication") ||
|
(err.message.includes("authentication") ||
|
||||||
@@ -1426,12 +1467,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
const hasStoredPassword =
|
const hasStoredPassword =
|
||||||
resolvedCredentials.password &&
|
resolvedCredentials.password &&
|
||||||
resolvedCredentials.authType !== "none" &&
|
resolvedCredentials.authType !== "none" &&
|
||||||
resolvedCredentials.authType !== "tailscale";
|
resolvedCredentials.authType !== "tailscale" &&
|
||||||
|
resolvedCredentials.authType !== "warpgate";
|
||||||
|
|
||||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||||
/password/i.test(p.prompt),
|
/password/i.test(p.prompt),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (resolvedCredentials.authType === "warpgate") {
|
||||||
|
finish(prompts.map(() => ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(resolvedCredentials.authType === "none" ||
|
(resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale") &&
|
resolvedCredentials.authType === "tailscale") &&
|
||||||
@@ -1512,16 +1559,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const proxyConfig: SOCKS5Config | null =
|
const proxyConfig: SOCKS5Config | null =
|
||||||
useSocks5 &&
|
resolvedUseSocks5 &&
|
||||||
(socks5Host ||
|
(resolvedSocks5Host ||
|
||||||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
|
(resolvedSocks5ProxyChain &&
|
||||||
|
(resolvedSocks5ProxyChain as ProxyNode[]).length > 0))
|
||||||
? {
|
? {
|
||||||
useSocks5,
|
useSocks5: resolvedUseSocks5,
|
||||||
socks5Host,
|
socks5Host: resolvedSocks5Host,
|
||||||
socks5Port,
|
socks5Port: resolvedSocks5Port,
|
||||||
socks5Username,
|
socks5Username: resolvedSocks5Username,
|
||||||
socks5Password,
|
socks5Password: resolvedSocks5Password,
|
||||||
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
|
socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[],
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -1571,7 +1619,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let forwardOutDone = false;
|
||||||
|
const forwardOutTimeout = setTimeout(() => {
|
||||||
|
if (!forwardOutDone) {
|
||||||
|
forwardOutDone = true;
|
||||||
|
fileLogger.error("Timeout waiting for jump host forwardOut", {
|
||||||
|
operation: "file_jump_forward_timeout",
|
||||||
|
sessionId,
|
||||||
|
hostId,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
});
|
||||||
|
connectionLogs.push(
|
||||||
|
createConnectionLog(
|
||||||
|
"error",
|
||||||
|
"jump",
|
||||||
|
"Timed out waiting for jump host tunnel to target host",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
jumpClient.end();
|
||||||
|
res.status(500).json({
|
||||||
|
error: "Jump host tunnel timed out",
|
||||||
|
connectionLogs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||||
|
if (forwardOutDone) return;
|
||||||
|
forwardOutDone = true;
|
||||||
|
clearTimeout(forwardOutTimeout);
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("Failed to forward through jump host", err, {
|
fileLogger.error("Failed to forward through jump host", err, {
|
||||||
operation: "file_jump_forward",
|
operation: "file_jump_forward",
|
||||||
|
|||||||
@@ -21,6 +21,37 @@ interface VerificationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SSHHostKeyVerifier {
|
export class SSHHostKeyVerifier {
|
||||||
|
/**
|
||||||
|
* Pre-fetches the host record from the database so the verifier callback
|
||||||
|
* during SSH key exchange doesn't need to do an async DB query. This keeps
|
||||||
|
* the key exchange critical path fast, avoiding LoginGraceTime expiry on
|
||||||
|
* the remote server (especially important for jump-host tunneled connections).
|
||||||
|
*/
|
||||||
|
static async preloadHostData(hostId: number | null): Promise<{
|
||||||
|
hostKeyFingerprint: string | null;
|
||||||
|
hostKeyType: string | null;
|
||||||
|
hostKeyAlgorithm: string | null;
|
||||||
|
hostKeyChangedCount: number | null;
|
||||||
|
name: string | null;
|
||||||
|
} | null> {
|
||||||
|
if (!hostId) return null;
|
||||||
|
try {
|
||||||
|
const host = await db.query.hosts.findFirst({
|
||||||
|
where: eq(hosts.id, hostId),
|
||||||
|
columns: {
|
||||||
|
hostKeyFingerprint: true,
|
||||||
|
hostKeyType: true,
|
||||||
|
hostKeyAlgorithm: true,
|
||||||
|
hostKeyChangedCount: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return host ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async createHostVerifier(
|
static async createHostVerifier(
|
||||||
hostId: number | null,
|
hostId: number | null,
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -28,6 +59,9 @@ export class SSHHostKeyVerifier {
|
|||||||
ws: WebSocket | null,
|
ws: WebSocket | null,
|
||||||
userId: string,
|
userId: string,
|
||||||
isJumpHost: boolean = false,
|
isJumpHost: boolean = false,
|
||||||
|
preloadedHost?: Awaited<
|
||||||
|
ReturnType<typeof SSHHostKeyVerifier.preloadHostData>
|
||||||
|
>,
|
||||||
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
|
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
|
||||||
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
|
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -52,9 +86,10 @@ export class SSHHostKeyVerifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = await db.query.hosts.findFirst({
|
const host =
|
||||||
where: eq(hosts.id, hostId),
|
preloadedHost !== undefined
|
||||||
});
|
? preloadedHost
|
||||||
|
: await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) });
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
sshLogger.warn(
|
sshLogger.warn(
|
||||||
@@ -141,13 +176,6 @@ export class SSHHostKeyVerifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (host.hostKeyFingerprint === fingerprint) {
|
if (host.hostKeyFingerprint === fingerprint) {
|
||||||
await db
|
|
||||||
.update(hosts)
|
|
||||||
.set({
|
|
||||||
hostKeyLastVerified: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.where(eq(hosts.id, hostId));
|
|
||||||
|
|
||||||
sshLogger.info("Host key verified successfully", {
|
sshLogger.info("Host key verified successfully", {
|
||||||
operation: "host_key_verified",
|
operation: "host_key_verified",
|
||||||
hostId,
|
hostId,
|
||||||
@@ -158,7 +186,18 @@ export class SSHHostKeyVerifier {
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Verify first, then update the timestamp asynchronously so the
|
||||||
|
// DB write doesn't delay the SSH key exchange critical path.
|
||||||
verify(true);
|
verify(true);
|
||||||
|
db.update(hosts)
|
||||||
|
.set({ hostKeyLastVerified: new Date().toISOString() })
|
||||||
|
.where(eq(hosts.id, hostId))
|
||||||
|
.catch((err) => {
|
||||||
|
sshLogger.error("Failed to update hostKeyLastVerified", err, {
|
||||||
|
operation: "host_key_update_timestamp",
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ interface SSHHostWithCredentials {
|
|||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
rdpPort?: number;
|
||||||
|
vncPort?: number;
|
||||||
|
telnetPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusEntry = {
|
type StatusEntry = {
|
||||||
@@ -348,7 +351,12 @@ class PollingManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let pingHost = refreshedHost.ip;
|
let pingHost = refreshedHost.ip;
|
||||||
let pingPort = refreshedHost.port;
|
const ct = refreshedHost.connectionType || "ssh";
|
||||||
|
let pingPort: number;
|
||||||
|
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
|
||||||
|
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
|
||||||
|
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
|
||||||
|
else pingPort = refreshedHost.port;
|
||||||
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
||||||
const firstJump = await fetchHostById(
|
const firstJump = await fetchHostById(
|
||||||
refreshedHost.jumpHosts[0].hostId,
|
refreshedHost.jumpHosts[0].hostId,
|
||||||
@@ -792,6 +800,12 @@ async function resolveHostCredentials(
|
|||||||
socks5ProxyChain: host.socks5ProxyChain
|
socks5ProxyChain: host.socks5ProxyChain
|
||||||
? JSON.parse(host.socks5ProxyChain as string)
|
? JSON.parse(host.socks5ProxyChain as string)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
connectionType:
|
||||||
|
(host.connectionType as SSHHostWithCredentials["connectionType"]) ||
|
||||||
|
"ssh",
|
||||||
|
rdpPort: (host.rdpPort as number | undefined) ?? undefined,
|
||||||
|
vncPort: (host.vncPort as number | undefined) ?? undefined,
|
||||||
|
telnetPort: (host.telnetPort as number | undefined) ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
@@ -1028,7 +1042,11 @@ async function buildSshConfig(
|
|||||||
cause: keyError,
|
cause: keyError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (host.authType === "none" || host.authType === "tailscale") {
|
} else if (
|
||||||
|
host.authType === "none" ||
|
||||||
|
host.authType === "tailscale" ||
|
||||||
|
host.authType === "warpgate"
|
||||||
|
) {
|
||||||
// no credentials needed
|
// no credentials needed
|
||||||
} else if (host.authType === "opkssh") {
|
} else if (host.authType === "opkssh") {
|
||||||
// cert auth setup happens in createSshFactory (needs client instance)
|
// cert auth setup happens in createSshFactory (needs client instance)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import { pickResolvedUsername } from "./credential-username.js";
|
import {
|
||||||
|
pickResolvedUsername,
|
||||||
|
expandOidcUsername,
|
||||||
|
} from "./credential-username.js";
|
||||||
import type { SSHHost } from "../../types/index.js";
|
import type { SSHHost } from "../../types/index.js";
|
||||||
|
|
||||||
const sshLogger = logger;
|
const sshLogger = logger;
|
||||||
@@ -120,6 +123,10 @@ export async function resolveHostById(
|
|||||||
: cred.password
|
: cred.password
|
||||||
? "password"
|
? "password"
|
||||||
: "none";
|
: "none";
|
||||||
|
host.username = await expandOidcUsername(
|
||||||
|
host.username as string | undefined,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
return host as unknown as SSHHost;
|
return host as unknown as SSHHost;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +157,10 @@ export async function resolveHostById(
|
|||||||
: sharedCred.password
|
: sharedCred.password
|
||||||
? "password"
|
? "password"
|
||||||
: "none";
|
: "none";
|
||||||
|
host.username = await expandOidcUsername(
|
||||||
|
host.username as string | undefined,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
return host as unknown as SSHHost;
|
return host as unknown as SSHHost;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -203,6 +214,11 @@ export async function resolveHostById(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host.username = await expandOidcUsername(
|
||||||
|
host.username as string | undefined,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
return host as unknown as SSHHost;
|
return host as unknown as SSHHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+130
-118
@@ -5,7 +5,7 @@ import ssh2Pkg, {
|
|||||||
type PseudoTtyOptions,
|
type PseudoTtyOptions,
|
||||||
} from "ssh2";
|
} from "ssh2";
|
||||||
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
||||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { hosts } from "../database/db/schema.js";
|
import { hosts } from "../database/db/schema.js";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
waitForTmuxSession,
|
waitForTmuxSession,
|
||||||
} from "./tmux-helper.js";
|
} from "./tmux-helper.js";
|
||||||
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
|
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
|
||||||
|
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||||
|
|
||||||
interface ConnectToHostData {
|
interface ConnectToHostData {
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -187,9 +188,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
let isConnecting = false;
|
let isConnecting = false;
|
||||||
let isConnected = false;
|
let isConnected = false;
|
||||||
let isCleaningUp = false;
|
let isCleaningUp = false;
|
||||||
let cwdPending = false;
|
|
||||||
let cwdBuffer = "";
|
|
||||||
let isShellInitializing = false;
|
let isShellInitializing = false;
|
||||||
|
let isDuplicateConnDiscarded = false;
|
||||||
let warpgateAuthPromptSent = false;
|
let warpgateAuthPromptSent = false;
|
||||||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||||||
let isAwaitingAuthCredentials = false;
|
let isAwaitingAuthCredentials = false;
|
||||||
@@ -237,7 +237,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const session = sessionManager.getSession(currentSessionId);
|
const session = sessionManager.getSession(currentSessionId);
|
||||||
if (session?.isConnected) {
|
if (session?.isConnected) {
|
||||||
|
// Only detach if this WS is still the one attached to the session.
|
||||||
|
// If a refresh reconnected and reattached a new WS before this close
|
||||||
|
// event fired, we must not clobber that new attachment.
|
||||||
|
if (session.attachedWs === ws || session.attachedWs === null) {
|
||||||
sessionManager.detachWs(currentSessionId);
|
sessionManager.detachWs(currentSessionId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
@@ -446,15 +451,80 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "get_cwd": {
|
case "get_cwd": {
|
||||||
const activeStream =
|
const activeConn =
|
||||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
||||||
if (!activeStream) {
|
if (!activeConn) {
|
||||||
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
cwdPending = true;
|
activeConn.exec("pwd", (err, execStream) => {
|
||||||
cwdBuffer = "";
|
if (err) {
|
||||||
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stdout = "";
|
||||||
|
execStream.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
execStream.stderr.on("data", () => {});
|
||||||
|
execStream.on("close", () => {
|
||||||
|
const cwd = stdout.trim() || "/";
|
||||||
|
const attachedWs =
|
||||||
|
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||||
|
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||||
|
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "open_file_in_editor": {
|
||||||
|
const { path: requestedPath } = data as { path: string };
|
||||||
|
const activeConn =
|
||||||
|
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
||||||
|
if (!activeConn || !requestedPath) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "open_file_in_editor",
|
||||||
|
path: requestedPath || "/",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const escapedPath = requestedPath.replace(/'/g, "'\\''");
|
||||||
|
activeConn.exec(
|
||||||
|
`realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`,
|
||||||
|
(err, execStream) => {
|
||||||
|
if (err) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "open_file_in_editor",
|
||||||
|
path: requestedPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stdout = "";
|
||||||
|
execStream.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
execStream.stderr.on("data", () => {});
|
||||||
|
execStream.on("close", () => {
|
||||||
|
const resolvedPath = stdout.trim() || requestedPath;
|
||||||
|
const attachedWs =
|
||||||
|
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||||
|
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||||
|
attachedWs.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "open_file_in_editor",
|
||||||
|
path: resolvedPath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,7 +1060,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) {
|
if (resolvedHostData.useSocks5) {
|
||||||
hostConfig.useSocks5 = resolvedHostData.useSocks5;
|
hostConfig.useSocks5 = resolvedHostData.useSocks5;
|
||||||
hostConfig.socks5Host = resolvedHostData.socks5Host;
|
hostConfig.socks5Host = resolvedHostData.socks5Host;
|
||||||
hostConfig.socks5Port = resolvedHostData.socks5Port;
|
hostConfig.socks5Port = resolvedHostData.socks5Port;
|
||||||
@@ -1134,27 +1204,43 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
!existingSession.sshStream.destroyed &&
|
!existingSession.sshStream.destroyed &&
|
||||||
existingSession.sshConn !== sshConn
|
existingSession.sshConn !== sshConn
|
||||||
) {
|
) {
|
||||||
|
const reusedSessionId = currentSessionId;
|
||||||
sshLogger.info(
|
sshLogger.info(
|
||||||
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
|
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
|
||||||
{
|
{
|
||||||
operation: "terminal_reuse_existing_session",
|
operation: "terminal_reuse_existing_session",
|
||||||
sessionId: currentSessionId,
|
sessionId: reusedSessionId,
|
||||||
tabInstanceId,
|
tabInstanceId,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Null out currentSessionId before ending the duplicate connection so
|
||||||
|
// the sshConn "close" handler does not destroy the reused session.
|
||||||
|
// Set isDuplicateConnDiscarded so the close handler does not send a
|
||||||
|
// "disconnected" message to the new WS that is now attached to the live session.
|
||||||
|
// Null out currentSessionId before ending the duplicate connection so
|
||||||
|
// the sshConn "close" handler does not destroy the reused session.
|
||||||
|
// Set isDuplicateConnDiscarded so the close handler exits without
|
||||||
|
// sending a "disconnected" message to the new WS.
|
||||||
|
currentSessionId = null;
|
||||||
|
isDuplicateConnDiscarded = true;
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
try {
|
try {
|
||||||
sshConn?.end();
|
sshConn?.end();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
sshConn = null;
|
sshConn = null;
|
||||||
|
sshStream = null;
|
||||||
|
|
||||||
|
// Point this WS handler's closure at the live session so the input
|
||||||
|
// handler can forward keystrokes via currentSessionId.
|
||||||
|
currentSessionId = reusedSessionId;
|
||||||
sshStream = existingSession.sshStream;
|
sshStream = existingSession.sshStream;
|
||||||
sshConn = existingSession.sshConn;
|
sshConn = existingSession.sshConn;
|
||||||
isConnecting = false;
|
isConnecting = false;
|
||||||
isConnected = true;
|
isConnected = true;
|
||||||
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
|
sessionManager.attachWs(reusedSessionId, userId, ws, tabInstanceId);
|
||||||
|
|
||||||
const buffered = sessionManager.getBuffer(existingSession);
|
const buffered = sessionManager.getBuffer(existingSession);
|
||||||
if (buffered) {
|
if (buffered) {
|
||||||
@@ -1163,20 +1249,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "sessionCreated",
|
type: "sessionCreated",
|
||||||
sessionId: currentSessionId,
|
sessionId: reusedSessionId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "sessionAttached",
|
type: "sessionAttached",
|
||||||
sessionId: currentSessionId,
|
sessionId: reusedSessionId,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ type: "connected", message: "Session reattached" }),
|
JSON.stringify({ type: "connected", message: "Session reattached" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
cleanupAuthState(connectionTimeout);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1350,44 +1434,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
const boundSessionId = currentSessionId;
|
const boundSessionId = currentSessionId;
|
||||||
|
|
||||||
const CWD_SENTINEL = "TERMIX_CWD:";
|
|
||||||
|
|
||||||
stream.on("data", (data: Buffer) => {
|
stream.on("data", (data: Buffer) => {
|
||||||
try {
|
try {
|
||||||
let utf8String = data.toString("utf-8");
|
const utf8String = data.toString("utf-8");
|
||||||
|
|
||||||
if (cwdPending) {
|
|
||||||
cwdBuffer += utf8String;
|
|
||||||
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
|
|
||||||
if (sentinelIdx !== -1) {
|
|
||||||
const afterSentinel = cwdBuffer.slice(
|
|
||||||
sentinelIdx + CWD_SENTINEL.length,
|
|
||||||
);
|
|
||||||
const newlineIdx = afterSentinel.search(/[\r\n]/);
|
|
||||||
if (newlineIdx !== -1) {
|
|
||||||
const cwd =
|
|
||||||
afterSentinel.slice(0, newlineIdx).trim() || "/";
|
|
||||||
cwdPending = false;
|
|
||||||
// Strip the sentinel line from output sent to terminal
|
|
||||||
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
|
|
||||||
const afterNewline = afterSentinel.slice(newlineIdx);
|
|
||||||
utf8String = beforeSentinel + afterNewline;
|
|
||||||
cwdBuffer = "";
|
|
||||||
const attachedWs =
|
|
||||||
sessionManager.getSession(boundSessionId)?.attachedWs ??
|
|
||||||
ws;
|
|
||||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
|
||||||
attachedWs.send(
|
|
||||||
JSON.stringify({ type: "cwd", path: cwd }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!utf8String) return;
|
if (!utf8String) return;
|
||||||
|
|
||||||
@@ -1475,7 +1524,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const runPostShellCommands = (delay: number) => {
|
const runPostShellCommands = (delay: number) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (initialPath && initialPath.trim() !== "") {
|
if (initialPath && initialPath.trim() !== "") {
|
||||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
let cdCommand: string;
|
||||||
|
if (isWindowsSftpPath(initialPath)) {
|
||||||
|
const winPath = sftpPathToLocalPath(initialPath);
|
||||||
|
const escaped = winPath.replace(/"/g, '""');
|
||||||
|
cdCommand = `cd "${escaped}"\r`;
|
||||||
|
} else {
|
||||||
|
cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
||||||
|
}
|
||||||
stream.write(cdCommand);
|
stream.write(cdCommand);
|
||||||
}
|
}
|
||||||
if (executeCommand && executeCommand.trim() !== "") {
|
if (executeCommand && executeCommand.trim() !== "") {
|
||||||
@@ -1543,27 +1599,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
runPostShellCommands(0);
|
runPostShellCommands(0);
|
||||||
} else if (detection.sessions.length === 1) {
|
|
||||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
|
||||||
const sessionName = detection.sessions[0].name;
|
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
|
||||||
if (session) {
|
|
||||||
session.tmuxSessionName = sessionName;
|
|
||||||
}
|
|
||||||
sshLogger.info("Auto-attached to existing tmux session", {
|
|
||||||
operation: "tmux_auto_attach",
|
|
||||||
sessionName,
|
|
||||||
hostId: id,
|
|
||||||
});
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "tmux_session_attached",
|
|
||||||
sessionName,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Reattaching to existing session -- don't re-run
|
|
||||||
// initialPath/executeCommand since the session already
|
|
||||||
// has its own state
|
|
||||||
} else {
|
} else {
|
||||||
sshLogger.info(
|
sshLogger.info(
|
||||||
"Multiple tmux sessions found, sending list to frontend",
|
"Multiple tmux sessions found, sending list to frontend",
|
||||||
@@ -1910,6 +1945,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
hostId: id,
|
hostId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isDuplicateConnDiscarded) {
|
||||||
|
cleanupAuthState(connectionTimeout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isAwaitingAuthCredentials) {
|
if (isAwaitingAuthCredentials) {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
@@ -1991,6 +2031,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
resolvedCredentials as unknown as Parameters<
|
resolvedCredentials as unknown as Parameters<
|
||||||
typeof sshAuthManager.handleKeyboardInteractive
|
typeof sshAuthManager.handleKeyboardInteractive
|
||||||
>[5],
|
>[5],
|
||||||
|
hostConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
|
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
|
||||||
@@ -2008,19 +2049,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
|
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
|
||||||
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
|
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
|
||||||
|
|
||||||
|
// Pre-fetch the stored host key before connect so the verifier callback
|
||||||
|
// runs synchronously during SSH key exchange, avoiding LoginGraceTime
|
||||||
|
// expiry on slow connections (especially through jump host tunnels).
|
||||||
|
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
|
||||||
|
|
||||||
const connectConfig: Record<string, unknown> = {
|
const connectConfig: Record<string, unknown> = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard:
|
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
||||||
resolvedCredentials.authType !== "none" &&
|
|
||||||
resolvedCredentials.authType !== "tailscale",
|
|
||||||
keepaliveInterval:
|
keepaliveInterval:
|
||||||
typeof hostKeepaliveInterval === "number"
|
typeof hostKeepaliveInterval === "number"
|
||||||
? hostKeepaliveInterval * 1000
|
? Math.max(5000, hostKeepaliveInterval * 1000)
|
||||||
: 30000,
|
: 30000,
|
||||||
keepaliveCountMax:
|
keepaliveCountMax:
|
||||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
|
typeof hostKeepaliveCountMax === "number"
|
||||||
|
? Math.max(1, hostKeepaliveCountMax)
|
||||||
|
: 5,
|
||||||
readyTimeout: 120000,
|
readyTimeout: 120000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
@@ -2032,6 +2078,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws,
|
ws,
|
||||||
userId,
|
userId,
|
||||||
false,
|
false,
|
||||||
|
preloadedHostData,
|
||||||
),
|
),
|
||||||
env: {
|
env: {
|
||||||
TERM: "xterm-256color",
|
TERM: "xterm-256color",
|
||||||
@@ -2045,51 +2092,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
LC_COLLATE: "en_US.UTF-8",
|
LC_COLLATE: "en_US.UTF-8",
|
||||||
COLORTERM: "truecolor",
|
COLORTERM: "truecolor",
|
||||||
},
|
},
|
||||||
algorithms: {
|
algorithms: buildSSHAlgorithms(
|
||||||
kex: [
|
hostConfig.terminalConfig?.allowLegacyAlgorithms !== false,
|
||||||
"curve25519-sha256",
|
),
|
||||||
"curve25519-sha256@libssh.org",
|
|
||||||
"ecdh-sha2-nistp521",
|
|
||||||
"ecdh-sha2-nistp384",
|
|
||||||
"ecdh-sha2-nistp256",
|
|
||||||
"diffie-hellman-group-exchange-sha256",
|
|
||||||
"diffie-hellman-group18-sha512",
|
|
||||||
"diffie-hellman-group17-sha512",
|
|
||||||
"diffie-hellman-group16-sha512",
|
|
||||||
"diffie-hellman-group15-sha512",
|
|
||||||
"diffie-hellman-group14-sha256",
|
|
||||||
"diffie-hellman-group14-sha1",
|
|
||||||
"diffie-hellman-group-exchange-sha1",
|
|
||||||
"diffie-hellman-group1-sha1",
|
|
||||||
],
|
|
||||||
serverHostKey: [
|
|
||||||
"ssh-ed25519",
|
|
||||||
"ecdsa-sha2-nistp521",
|
|
||||||
"ecdsa-sha2-nistp384",
|
|
||||||
"ecdsa-sha2-nistp256",
|
|
||||||
"rsa-sha2-512",
|
|
||||||
"rsa-sha2-256",
|
|
||||||
"ssh-rsa",
|
|
||||||
"ssh-dss",
|
|
||||||
],
|
|
||||||
cipher: SSH_ALGORITHMS.cipher,
|
|
||||||
hmac: [
|
|
||||||
"hmac-sha2-512-etm@openssh.com",
|
|
||||||
"hmac-sha2-256-etm@openssh.com",
|
|
||||||
"hmac-sha2-512",
|
|
||||||
"hmac-sha2-256",
|
|
||||||
"hmac-sha1",
|
|
||||||
"hmac-md5",
|
|
||||||
],
|
|
||||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resolvedCredentials.authType === "none" ||
|
resolvedCredentials.authType === "none" ||
|
||||||
resolvedCredentials.authType === "tailscale"
|
resolvedCredentials.authType === "tailscale"
|
||||||
) {
|
) {
|
||||||
// Tailscale SSH and "none" auth: daemon handles authorization, no credentials needed
|
// Tailscale SSH and "none": no static credentials needed
|
||||||
} else if (resolvedCredentials.authType === "password") {
|
} else if (resolvedCredentials.authType === "password") {
|
||||||
if (!resolvedCredentials.password) {
|
if (!resolvedCredentials.password) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
|
import type {
|
||||||
|
ConnectConfig,
|
||||||
|
CipherAlgorithm,
|
||||||
|
KexAlgorithm,
|
||||||
|
ServerHostKeyAlgorithm,
|
||||||
|
MacAlgorithm,
|
||||||
|
} from "ssh2";
|
||||||
|
|
||||||
const nativeRequire = createRequire(import.meta.url);
|
const nativeRequire = createRequire(import.meta.url);
|
||||||
const availableCiphers = new Set(crypto.getCiphers());
|
const availableCiphers = new Set(crypto.getCiphers());
|
||||||
@@ -47,6 +53,76 @@ function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEGACY_KEX: KexAlgorithm[] = [
|
||||||
|
"diffie-hellman-group14-sha1",
|
||||||
|
"diffie-hellman-group-exchange-sha1",
|
||||||
|
"diffie-hellman-group1-sha1",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEGACY_SERVER_HOST_KEY: ServerHostKeyAlgorithm[] = ["ssh-rsa", "ssh-dss"];
|
||||||
|
|
||||||
|
const LEGACY_HMAC: MacAlgorithm[] = ["hmac-sha1", "hmac-md5"];
|
||||||
|
|
||||||
|
const LEGACY_CIPHER = filterCiphers(["3des-cbc"]);
|
||||||
|
|
||||||
|
export function buildSSHAlgorithms(
|
||||||
|
allowLegacy: boolean,
|
||||||
|
): NonNullable<ConnectConfig["algorithms"]> {
|
||||||
|
const kex: KexAlgorithm[] = [
|
||||||
|
"curve25519-sha256",
|
||||||
|
"curve25519-sha256@libssh.org",
|
||||||
|
"ecdh-sha2-nistp521",
|
||||||
|
"ecdh-sha2-nistp384",
|
||||||
|
"ecdh-sha2-nistp256",
|
||||||
|
"diffie-hellman-group-exchange-sha256",
|
||||||
|
"diffie-hellman-group18-sha512",
|
||||||
|
"diffie-hellman-group17-sha512",
|
||||||
|
"diffie-hellman-group16-sha512",
|
||||||
|
"diffie-hellman-group15-sha512",
|
||||||
|
"diffie-hellman-group14-sha256",
|
||||||
|
];
|
||||||
|
const serverHostKey: ServerHostKeyAlgorithm[] = [
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"rsa-sha2-512",
|
||||||
|
"rsa-sha2-256",
|
||||||
|
];
|
||||||
|
const hmac: MacAlgorithm[] = [
|
||||||
|
"hmac-sha2-512-etm@openssh.com",
|
||||||
|
"hmac-sha2-256-etm@openssh.com",
|
||||||
|
"hmac-sha2-512",
|
||||||
|
"hmac-sha2-256",
|
||||||
|
];
|
||||||
|
const cipher = filterCiphers([
|
||||||
|
"chacha20-poly1305@openssh.com",
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
|
"aes128-gcm@openssh.com",
|
||||||
|
"aes256-ctr",
|
||||||
|
"aes192-ctr",
|
||||||
|
"aes128-ctr",
|
||||||
|
"aes256-cbc",
|
||||||
|
"aes192-cbc",
|
||||||
|
"aes128-cbc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allowLegacy) {
|
||||||
|
kex.push(...LEGACY_KEX);
|
||||||
|
serverHostKey.push(...LEGACY_SERVER_HOST_KEY);
|
||||||
|
hmac.push(...LEGACY_HMAC);
|
||||||
|
cipher.push(...LEGACY_CIPHER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kex,
|
||||||
|
serverHostKey,
|
||||||
|
cipher,
|
||||||
|
hmac,
|
||||||
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
|
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
|
||||||
kex: [
|
kex: [
|
||||||
"curve25519-sha256",
|
"curve25519-sha256",
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { isValidMac, buildMagicPacket } from "./wake-on-lan.js";
|
|
||||||
|
vi.mock("dgram", () => {
|
||||||
|
const socket = {
|
||||||
|
once: vi.fn(),
|
||||||
|
bind: vi.fn(),
|
||||||
|
setBroadcast: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
};
|
||||||
|
return { default: { createSocket: vi.fn(() => socket) } };
|
||||||
|
});
|
||||||
|
|
||||||
|
import dgram from "dgram";
|
||||||
|
import { isValidMac, buildMagicPacket, sendWakeOnLan } from "./wake-on-lan.js";
|
||||||
|
|
||||||
describe("isValidMac", () => {
|
describe("isValidMac", () => {
|
||||||
it("accepts colon-separated MAC addresses", () => {
|
it("accepts colon-separated MAC addresses", () => {
|
||||||
@@ -49,3 +62,55 @@ describe("buildMagicPacket", () => {
|
|||||||
expect(colon.equals(hyphen)).toBe(true);
|
expect(colon.equals(hyphen)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sendWakeOnLan", () => {
|
||||||
|
let mockSocket: ReturnType<typeof dgram.createSocket>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSocket = (dgram.createSocket as ReturnType<typeof vi.fn>)();
|
||||||
|
(mockSocket.bind as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
(cb: () => void) => cb(),
|
||||||
|
);
|
||||||
|
(mockSocket.send as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
(
|
||||||
|
_buf: unknown,
|
||||||
|
_off: unknown,
|
||||||
|
_len: unknown,
|
||||||
|
_port: unknown,
|
||||||
|
_addr: unknown,
|
||||||
|
cb: (err: null) => void,
|
||||||
|
) => cb(null),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects on invalid MAC address", async () => {
|
||||||
|
await expect(sendWakeOnLan("not-a-mac")).rejects.toThrow(
|
||||||
|
"Invalid MAC address",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to 255.255.255.255 by default", async () => {
|
||||||
|
await sendWakeOnLan("aa:bb:cc:dd:ee:ff");
|
||||||
|
expect(mockSocket.send).toHaveBeenCalledWith(
|
||||||
|
expect.any(Buffer),
|
||||||
|
0,
|
||||||
|
102,
|
||||||
|
9,
|
||||||
|
"255.255.255.255",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to a custom broadcast address when provided", async () => {
|
||||||
|
await sendWakeOnLan("aa:bb:cc:dd:ee:ff", "192.168.1.255");
|
||||||
|
expect(mockSocket.send).toHaveBeenCalledWith(
|
||||||
|
expect.any(Buffer),
|
||||||
|
0,
|
||||||
|
102,
|
||||||
|
9,
|
||||||
|
"192.168.1.255",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export function isValidMac(mac: string): boolean {
|
|||||||
return MAC_REGEX.test(mac);
|
return MAC_REGEX.test(mac);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendWakeOnLan(mac: string): Promise<void> {
|
export function sendWakeOnLan(
|
||||||
|
mac: string,
|
||||||
|
broadcastAddress = "255.255.255.255",
|
||||||
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!isValidMac(mac)) {
|
if (!isValidMac(mac)) {
|
||||||
return reject(new Error("Invalid MAC address"));
|
return reject(new Error("Invalid MAC address"));
|
||||||
@@ -36,7 +39,7 @@ export function sendWakeOnLan(mac: string): Promise<void> {
|
|||||||
|
|
||||||
socket.bind(() => {
|
socket.bind(() => {
|
||||||
socket.setBroadcast(true);
|
socket.setBroadcast(true);
|
||||||
socket.send(packet, 0, packet.length, 9, "255.255.255.255", (err) => {
|
socket.send(packet, 0, packet.length, 9, broadcastAddress, (err) => {
|
||||||
socket.close();
|
socket.close();
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
|
|||||||
+17
-2
@@ -70,6 +70,7 @@ function FullscreenApp() {
|
|||||||
const view = searchParams.get("view");
|
const view = searchParams.get("view");
|
||||||
const hostId = searchParams.get("hostId");
|
const hostId = searchParams.get("hostId");
|
||||||
const tmuxSession = searchParams.get("tmuxSession");
|
const tmuxSession = searchParams.get("tmuxSession");
|
||||||
|
const path = searchParams.get("path");
|
||||||
|
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "terminal":
|
case "terminal":
|
||||||
@@ -80,7 +81,12 @@ function FullscreenApp() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "file-manager":
|
case "file-manager":
|
||||||
return <FileManagerApp hostId={hostId || undefined} />;
|
return (
|
||||||
|
<FileManagerApp
|
||||||
|
hostId={hostId || undefined}
|
||||||
|
initialPath={path || undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "tunnel":
|
case "tunnel":
|
||||||
return <TunnelApp hostId={hostId || undefined} />;
|
return <TunnelApp hostId={hostId || undefined} />;
|
||||||
case "host-metrics":
|
case "host-metrics":
|
||||||
@@ -168,6 +174,11 @@ function App() {
|
|||||||
}, 450);
|
}, 450);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChangeServer() {
|
||||||
|
localStorage.setItem("termix_show_server_config", "true");
|
||||||
|
handleLogout();
|
||||||
|
}
|
||||||
|
|
||||||
const showApp =
|
const showApp =
|
||||||
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
||||||
const showAuth =
|
const showAuth =
|
||||||
@@ -211,7 +222,11 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AppShell username={authUsername} onLogout={handleLogout} />
|
<AppShell
|
||||||
|
username={authUsername}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
onChangeServer={handleChangeServer}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface OIDCProviderConfig {
|
|||||||
allowed_users?: string;
|
allowed_users?: string;
|
||||||
admin_group?: string;
|
admin_group?: string;
|
||||||
group_claim?: string;
|
group_claim?: string;
|
||||||
|
ca_cert?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LDAPProviderConfig {
|
export interface LDAPProviderConfig {
|
||||||
@@ -64,6 +65,7 @@ export type SSHAuthType =
|
|||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
| "tailscale";
|
| "tailscale";
|
||||||
|
|
||||||
export type GuacamoleAuthType = "password" | "credential";
|
export type GuacamoleAuthType = "password" | "credential";
|
||||||
|
|
||||||
export interface ProxmoxConfig {
|
export interface ProxmoxConfig {
|
||||||
@@ -101,6 +103,7 @@ export interface Host {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
||||||
|
useWarpgate?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
@@ -120,6 +123,7 @@ export interface Host {
|
|||||||
enableCommandHistory: boolean;
|
enableCommandHistory: boolean;
|
||||||
enableTunnel: boolean;
|
enableTunnel: boolean;
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
|
scpLegacy?: boolean;
|
||||||
enableDocker: boolean;
|
enableDocker: boolean;
|
||||||
enableProxmox: boolean;
|
enableProxmox: boolean;
|
||||||
enableTmuxMonitor: boolean;
|
enableTmuxMonitor: boolean;
|
||||||
@@ -145,6 +149,7 @@ export interface Host {
|
|||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
|
|
||||||
macAddress?: string;
|
macAddress?: string;
|
||||||
|
wolBroadcastAddress?: string;
|
||||||
portKnockSequence?: Array<{
|
portKnockSequence?: Array<{
|
||||||
port: number;
|
port: number;
|
||||||
protocol?: "tcp" | "udp";
|
protocol?: "tcp" | "udp";
|
||||||
@@ -165,15 +170,21 @@ export interface Host {
|
|||||||
rdpPort?: number;
|
rdpPort?: number;
|
||||||
vncPort?: number;
|
vncPort?: number;
|
||||||
telnetPort?: number;
|
telnetPort?: number;
|
||||||
|
rdpCredentialId?: number | null;
|
||||||
rdpUser?: string;
|
rdpUser?: string;
|
||||||
rdpPassword?: string;
|
rdpPassword?: string;
|
||||||
rdpDomain?: string;
|
rdpDomain?: string;
|
||||||
rdpSecurity?: string;
|
rdpSecurity?: string;
|
||||||
rdpIgnoreCert?: boolean;
|
rdpIgnoreCert?: boolean;
|
||||||
|
vncCredentialId?: number | null;
|
||||||
vncPassword?: string;
|
vncPassword?: string;
|
||||||
vncUser?: string;
|
vncUser?: string;
|
||||||
telnetUser?: string;
|
telnetUser?: string;
|
||||||
telnetPassword?: string;
|
telnetPassword?: string;
|
||||||
|
telnetCredentialId?: number | null;
|
||||||
|
rdpAuthType?: "direct" | "credential" | null;
|
||||||
|
vncAuthType?: "direct" | "credential" | null;
|
||||||
|
telnetAuthType?: "direct" | "credential" | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
@@ -211,6 +222,7 @@ export interface HostData {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
||||||
|
useWarpgate?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: File | null;
|
key?: File | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
@@ -223,6 +235,7 @@ export interface HostData {
|
|||||||
enableCommandHistory?: boolean;
|
enableCommandHistory?: boolean;
|
||||||
enableTunnel?: boolean;
|
enableTunnel?: boolean;
|
||||||
enableFileManager?: boolean;
|
enableFileManager?: boolean;
|
||||||
|
scpLegacy?: boolean;
|
||||||
enableDocker?: boolean;
|
enableDocker?: boolean;
|
||||||
enableProxmox?: boolean;
|
enableProxmox?: boolean;
|
||||||
enableTmuxMonitor?: boolean;
|
enableTmuxMonitor?: boolean;
|
||||||
@@ -249,6 +262,7 @@ export interface HostData {
|
|||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
|
|
||||||
macAddress?: string;
|
macAddress?: string;
|
||||||
|
wolBroadcastAddress?: string;
|
||||||
portKnockSequence?: Array<{
|
portKnockSequence?: Array<{
|
||||||
port: number;
|
port: number;
|
||||||
protocol?: "tcp" | "udp";
|
protocol?: "tcp" | "udp";
|
||||||
@@ -270,15 +284,21 @@ export interface HostData {
|
|||||||
rdpPort?: number;
|
rdpPort?: number;
|
||||||
vncPort?: number;
|
vncPort?: number;
|
||||||
telnetPort?: number;
|
telnetPort?: number;
|
||||||
|
rdpCredentialId?: number | null;
|
||||||
rdpUser?: string;
|
rdpUser?: string;
|
||||||
rdpPassword?: string;
|
rdpPassword?: string;
|
||||||
rdpDomain?: string;
|
rdpDomain?: string;
|
||||||
rdpSecurity?: string;
|
rdpSecurity?: string;
|
||||||
rdpIgnoreCert?: boolean;
|
rdpIgnoreCert?: boolean;
|
||||||
|
vncCredentialId?: number | null;
|
||||||
vncPassword?: string;
|
vncPassword?: string;
|
||||||
vncUser?: string;
|
vncUser?: string;
|
||||||
telnetUser?: string;
|
telnetUser?: string;
|
||||||
telnetPassword?: string;
|
telnetPassword?: string;
|
||||||
|
telnetCredentialId?: number | null;
|
||||||
|
rdpAuthType?: "direct" | "credential" | null;
|
||||||
|
vncAuthType?: "direct" | "credential" | null;
|
||||||
|
telnetAuthType?: "direct" | "credential" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SSHHost = Host;
|
export type SSHHost = Host;
|
||||||
@@ -597,6 +617,11 @@ export interface TerminalConfig {
|
|||||||
urls: boolean;
|
urls: boolean;
|
||||||
numbers: boolean;
|
numbers: boolean;
|
||||||
};
|
};
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundImageOpacity?: number;
|
||||||
|
allowLegacyAlgorithms?: boolean;
|
||||||
|
linkClickBehavior?: "confirm" | "direct";
|
||||||
|
useSSHTitle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+16
-2
@@ -11,6 +11,7 @@ export type Host = {
|
|||||||
lastAccess: string;
|
lastAccess: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
authType: "password" | "key" | "credential" | "none" | "opkssh" | "tailscale";
|
||||||
|
useWarpgate?: boolean;
|
||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
overrideCredentialUsername?: boolean;
|
overrideCredentialUsername?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -24,6 +25,7 @@ export type Host = {
|
|||||||
keyType?: string;
|
keyType?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
macAddress?: string;
|
macAddress?: string;
|
||||||
|
wolBroadcastAddress?: string;
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
|
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
@@ -53,6 +55,7 @@ export type Host = {
|
|||||||
keepaliveCountMax?: number;
|
keepaliveCountMax?: number;
|
||||||
environmentVariables: { key: string; value: string }[];
|
environmentVariables: { key: string; value: string }[];
|
||||||
startupSnippetId?: number | null;
|
startupSnippetId?: number | null;
|
||||||
|
linkClickBehavior?: "confirm" | "direct";
|
||||||
};
|
};
|
||||||
|
|
||||||
useSocks5?: boolean;
|
useSocks5?: boolean;
|
||||||
@@ -88,6 +91,7 @@ export type Host = {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
enableFileManager: boolean;
|
enableFileManager: boolean;
|
||||||
|
scpLegacy?: boolean;
|
||||||
defaultPath?: string;
|
defaultPath?: string;
|
||||||
|
|
||||||
enableDocker: boolean;
|
enableDocker: boolean;
|
||||||
@@ -95,6 +99,7 @@ export type Host = {
|
|||||||
enableTmuxMonitor: boolean;
|
enableTmuxMonitor: boolean;
|
||||||
proxmoxConfig?: {
|
proxmoxConfig?: {
|
||||||
defaultCredentialId: number | null;
|
defaultCredentialId: number | null;
|
||||||
|
defaultAuthType?: string;
|
||||||
windowsPatterns: string;
|
windowsPatterns: string;
|
||||||
dockerPatterns: string;
|
dockerPatterns: string;
|
||||||
preferredPrefixes: string;
|
preferredPrefixes: string;
|
||||||
@@ -121,12 +126,14 @@ export type Host = {
|
|||||||
vncPort: number;
|
vncPort: number;
|
||||||
telnetPort: number;
|
telnetPort: number;
|
||||||
|
|
||||||
|
rdpCredentialId?: string;
|
||||||
rdpUser?: string;
|
rdpUser?: string;
|
||||||
rdpPassword?: string;
|
rdpPassword?: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
security?: string;
|
security?: string;
|
||||||
ignoreCert?: boolean;
|
ignoreCert?: boolean;
|
||||||
|
|
||||||
|
vncCredentialId?: string;
|
||||||
vncPassword?: string;
|
vncPassword?: string;
|
||||||
vncUser?: string;
|
vncUser?: string;
|
||||||
|
|
||||||
@@ -201,14 +208,17 @@ export type Tab = {
|
|||||||
instanceId: string;
|
instanceId: string;
|
||||||
type: TabType;
|
type: TabType;
|
||||||
label: string;
|
label: string;
|
||||||
|
customLabel?: string;
|
||||||
host?: Host;
|
host?: Host;
|
||||||
openedAt: number;
|
openedAt: number;
|
||||||
restoredSessionId?: string | null;
|
restoredSessionId?: string | null;
|
||||||
|
initialFilePath?: string;
|
||||||
terminalRef?: import("react").RefObject<{
|
terminalRef?: import("react").RefObject<{
|
||||||
sendInput?: (data: string) => void;
|
sendInput?: (data: string) => void;
|
||||||
reconnect?: () => void;
|
reconnect?: () => void;
|
||||||
fit?: () => void;
|
fit?: () => void;
|
||||||
notifyResize?: () => void;
|
notifyResize?: () => void;
|
||||||
|
getApplicationCursorKeysMode?: () => boolean;
|
||||||
} | null>;
|
} | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,7 +246,8 @@ export type DashboardCardId =
|
|||||||
| "quick_actions"
|
| "quick_actions"
|
||||||
| "host_status"
|
| "host_status"
|
||||||
| "recent_activity"
|
| "recent_activity"
|
||||||
| "network_graph";
|
| "network_graph"
|
||||||
|
| "service_links";
|
||||||
|
|
||||||
export type DashboardCardConfig = {
|
export type DashboardCardConfig = {
|
||||||
id: DashboardCardId;
|
id: DashboardCardId;
|
||||||
@@ -275,9 +286,11 @@ export type AdminSection =
|
|||||||
| "users"
|
| "users"
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "roles"
|
| "roles"
|
||||||
|
| "host-defaults"
|
||||||
| "database"
|
| "database"
|
||||||
| "api-keys"
|
| "api-keys"
|
||||||
| "audit-log";
|
| "audit-log"
|
||||||
|
| "ssl";
|
||||||
export type AccentColorId = string;
|
export type AccentColorId = string;
|
||||||
export type ThemeId =
|
export type ThemeId =
|
||||||
| "dark"
|
| "dark"
|
||||||
@@ -309,6 +322,7 @@ export type Snippet = {
|
|||||||
content: string;
|
content: string;
|
||||||
folder: string | null;
|
folder: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
|
hostIds?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FOLDER_ICONS = [
|
export const FOLDER_ICONS = [
|
||||||
|
|||||||
+272
-5
@@ -36,6 +36,7 @@ import type {
|
|||||||
FontSizeId,
|
FontSizeId,
|
||||||
} from "@/types/ui-types";
|
} from "@/types/ui-types";
|
||||||
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
||||||
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
import {
|
import {
|
||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
@@ -80,7 +81,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
||||||
enableTerminal: h.enableTerminal ?? true,
|
enableTerminal: h.enableTerminal ?? true,
|
||||||
enableTunnel: h.enableTunnel ?? false,
|
enableTunnel: h.enableTunnel ?? false,
|
||||||
enableFileManager: h.enableFileManager ?? false,
|
enableFileManager: h.enableFileManager ?? true,
|
||||||
enableDocker: h.enableDocker ?? false,
|
enableDocker: h.enableDocker ?? false,
|
||||||
enableProxmox: h.enableProxmox ?? false,
|
enableProxmox: h.enableProxmox ?? false,
|
||||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
|
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
|
||||||
@@ -108,6 +109,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
socks5Username: h.socks5Username,
|
socks5Username: h.socks5Username,
|
||||||
socks5Password: h.socks5Password,
|
socks5Password: h.socks5Password,
|
||||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||||
|
statsConfig: (typeof h.statsConfig === "string"
|
||||||
|
? JSON.parse(h.statsConfig)
|
||||||
|
: h.statsConfig) as Host["statsConfig"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,9 +165,11 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
|||||||
export function AppShell({
|
export function AppShell({
|
||||||
username,
|
username,
|
||||||
onLogout,
|
onLogout,
|
||||||
|
onChangeServer,
|
||||||
}: {
|
}: {
|
||||||
username: string;
|
username: string;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
onChangeServer?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
@@ -193,6 +199,9 @@ export function AppShell({
|
|||||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||||
Array(6).fill(null),
|
Array(6).fill(null),
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
paneTabIdsRef.current = paneTabIds;
|
||||||
|
}, [paneTabIds]);
|
||||||
const [focusedPaneIndex, setFocusedPaneIndex] = useState<number | null>(null);
|
const [focusedPaneIndex, setFocusedPaneIndex] = useState<number | null>(null);
|
||||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||||
const [hostsLoading, setHostsLoading] = useState(true);
|
const [hostsLoading, setHostsLoading] = useState(true);
|
||||||
@@ -204,7 +213,6 @@ export function AppShell({
|
|||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [railView, setRailView] = useState<RailView>("hosts");
|
const [railView, setRailView] = useState<RailView>("hosts");
|
||||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||||
return saved ? parseInt(saved, 10) : 291;
|
return saved ? parseInt(saved, 10) : 291;
|
||||||
@@ -243,6 +251,26 @@ export function AppShell({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const lastShiftTime = useRef(0);
|
const lastShiftTime = useRef(0);
|
||||||
|
const tabsRef = useRef(tabs);
|
||||||
|
const activeTabIdRef = useRef(activeTabId);
|
||||||
|
const splitModeRef = useRef(splitMode);
|
||||||
|
const focusedPaneIndexRef = useRef<number | null>(null);
|
||||||
|
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
|
||||||
|
Array(6).fill(null),
|
||||||
|
);
|
||||||
|
const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null));
|
||||||
|
useEffect(() => {
|
||||||
|
tabsRef.current = tabs;
|
||||||
|
}, [tabs]);
|
||||||
|
useEffect(() => {
|
||||||
|
activeTabIdRef.current = activeTabId;
|
||||||
|
}, [activeTabId]);
|
||||||
|
useEffect(() => {
|
||||||
|
splitModeRef.current = splitMode;
|
||||||
|
}, [splitMode]);
|
||||||
|
useEffect(() => {
|
||||||
|
focusedPaneIndexRef.current = focusedPaneIndex;
|
||||||
|
}, [focusedPaneIndex]);
|
||||||
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||||
useState<boolean>(() => {
|
useState<boolean>(() => {
|
||||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||||
@@ -254,6 +282,9 @@ export function AppShell({
|
|||||||
const [paneContentEls, setPaneContentEls] = useState<
|
const [paneContentEls, setPaneContentEls] = useState<
|
||||||
(HTMLDivElement | null)[]
|
(HTMLDivElement | null)[]
|
||||||
>(Array(6).fill(null));
|
>(Array(6).fill(null));
|
||||||
|
useEffect(() => {
|
||||||
|
paneContentElsRef.current = paneContentEls;
|
||||||
|
}, [paneContentEls]);
|
||||||
|
|
||||||
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
||||||
// We always portal each tab's content into its own node, then move that node between
|
// We always portal each tab's content into its own node, then move that node between
|
||||||
@@ -314,6 +345,174 @@ export function AppShell({
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [commandPaletteShortcutEnabled]);
|
}, [commandPaletteShortcutEnabled]);
|
||||||
|
|
||||||
|
// Split-screen and tab navigation hotkeys
|
||||||
|
// Also registered in globalShortcutHandler so xterm can invoke directly
|
||||||
|
// without going through synthetic DOM events (which are unreliable).
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+Shift+\ — toggle 2-way split (side by side)
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (splitModeRef.current !== "none") {
|
||||||
|
splitModeRef.current = "none";
|
||||||
|
setSplitMode("none");
|
||||||
|
setPaneTabIds(Array(6).fill(null));
|
||||||
|
} else {
|
||||||
|
const mode = "2-way";
|
||||||
|
splitModeRef.current = mode;
|
||||||
|
const currentTabs = tabsRef.current;
|
||||||
|
const currentActiveId = activeTabIdRef.current;
|
||||||
|
const count = PANE_COUNTS[mode];
|
||||||
|
const next: (string | null)[] = Array(6).fill(null);
|
||||||
|
next[0] = currentActiveId;
|
||||||
|
let slot = 1;
|
||||||
|
for (const tab of currentTabs) {
|
||||||
|
if (slot >= count) break;
|
||||||
|
if (tab.id !== currentActiveId && tab.type !== "dashboard") {
|
||||||
|
next[slot] = tab.id;
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSplitMode(mode);
|
||||||
|
setPaneTabIds(next);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom)
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Minus") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (splitModeRef.current !== "none") {
|
||||||
|
splitModeRef.current = "none";
|
||||||
|
setSplitMode("none");
|
||||||
|
setPaneTabIds(Array(6).fill(null));
|
||||||
|
} else {
|
||||||
|
const mode = "3-way-horizontal";
|
||||||
|
splitModeRef.current = mode;
|
||||||
|
const currentTabs = tabsRef.current;
|
||||||
|
const currentActiveId = activeTabIdRef.current;
|
||||||
|
const count = PANE_COUNTS[mode];
|
||||||
|
const next: (string | null)[] = Array(6).fill(null);
|
||||||
|
next[0] = currentActiveId;
|
||||||
|
let slot = 1;
|
||||||
|
for (const tab of currentTabs) {
|
||||||
|
if (slot >= count) break;
|
||||||
|
if (tab.id !== currentActiveId && tab.type !== "dashboard") {
|
||||||
|
next[slot] = tab.id;
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSplitMode(mode);
|
||||||
|
setPaneTabIds(next);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+Arrow — navigate between panes in split mode
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||||
|
if (
|
||||||
|
e.code === "ArrowLeft" ||
|
||||||
|
e.code === "ArrowRight" ||
|
||||||
|
e.code === "ArrowUp" ||
|
||||||
|
e.code === "ArrowDown"
|
||||||
|
) {
|
||||||
|
if (splitModeRef.current === "none") return;
|
||||||
|
const count = PANE_COUNTS[splitModeRef.current];
|
||||||
|
if (count < 2) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const current = focusedPaneIndexRef.current ?? 0;
|
||||||
|
const mode = splitModeRef.current;
|
||||||
|
const dir = e.code;
|
||||||
|
|
||||||
|
// Layout-aware navigation maps: [left, right, up, down] per pane index.
|
||||||
|
// null means no movement in that direction.
|
||||||
|
const navMap: Record<string, (number | null)[][]> = {
|
||||||
|
"2-way": [
|
||||||
|
[null, 1, null, null],
|
||||||
|
[0, null, null, null],
|
||||||
|
],
|
||||||
|
"3-way": [
|
||||||
|
[null, 1, null, null],
|
||||||
|
[0, null, null, 2],
|
||||||
|
[0, null, 1, null],
|
||||||
|
],
|
||||||
|
"3-way-horizontal": [
|
||||||
|
[null, 1, null, 2],
|
||||||
|
[0, null, null, 2],
|
||||||
|
[null, null, 0, null],
|
||||||
|
],
|
||||||
|
"4-way": [
|
||||||
|
[null, 1, null, 2],
|
||||||
|
[0, null, null, 3],
|
||||||
|
[null, 3, 0, null],
|
||||||
|
[2, null, 1, null],
|
||||||
|
],
|
||||||
|
"5-way": [
|
||||||
|
[null, 1, null, 3],
|
||||||
|
[0, 2, null, 4],
|
||||||
|
[1, null, null, 4],
|
||||||
|
[null, 4, 0, null],
|
||||||
|
[3, null, 1, null],
|
||||||
|
],
|
||||||
|
"6-way": [
|
||||||
|
[null, 1, null, 3],
|
||||||
|
[0, 2, null, 4],
|
||||||
|
[1, null, null, 5],
|
||||||
|
[null, 4, 0, null],
|
||||||
|
[3, 5, 1, null],
|
||||||
|
[4, null, 2, null],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const paneNav = navMap[mode]?.[current];
|
||||||
|
const dirIndex =
|
||||||
|
{ ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3 }[dir] ??
|
||||||
|
-1;
|
||||||
|
const next = paneNav?.[dirIndex] ?? null;
|
||||||
|
if (next === null) return;
|
||||||
|
|
||||||
|
focusedPaneIndexRef.current = next;
|
||||||
|
setFocusedPaneIndex(next);
|
||||||
|
// Physically move DOM focus into the target pane's terminal
|
||||||
|
const tabId = paneTabIdsRef.current[next];
|
||||||
|
if (tabId) {
|
||||||
|
const termRef = terminalRefs.current.get(tabId);
|
||||||
|
(
|
||||||
|
termRef?.current as
|
||||||
|
| import("@/features/terminal/Terminal").TerminalHandle
|
||||||
|
| null
|
||||||
|
)?.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+] / Ctrl+Shift+[ — cycle through open tabs (] = next, [ = previous)
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
const currentTabs = tabsRef.current;
|
||||||
|
if (currentTabs.length < 2) return;
|
||||||
|
const currentId = activeTabIdRef.current;
|
||||||
|
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
||||||
|
const next =
|
||||||
|
e.code === "BracketRight"
|
||||||
|
? (idx + 1) % currentTabs.length
|
||||||
|
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
||||||
|
setActiveTabId(currentTabs[next].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
globalShortcutHandler.current = handleKeyDown;
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
globalShortcutHandler.current = null;
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||||
@@ -623,11 +822,17 @@ export function AppShell({
|
|||||||
const restoredSessionId =
|
const restoredSessionId =
|
||||||
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
||||||
|
|
||||||
|
const isCustomLabel =
|
||||||
|
host &&
|
||||||
|
saved.label !== host.name &&
|
||||||
|
!/^.+ \(\d+\)$/.test(saved.label);
|
||||||
|
|
||||||
restoredTabs.push({
|
restoredTabs.push({
|
||||||
id: tabId,
|
id: tabId,
|
||||||
instanceId: saved.id,
|
instanceId: saved.id,
|
||||||
type: saved.tabType as TabType,
|
type: saved.tabType as TabType,
|
||||||
label: saved.label,
|
label: saved.label,
|
||||||
|
customLabel: isCustomLabel ? saved.label : undefined,
|
||||||
host,
|
host,
|
||||||
openedAt: new Date(saved.createdAt).getTime(),
|
openedAt: new Date(saved.createdAt).getTime(),
|
||||||
restoredSessionId,
|
restoredSessionId,
|
||||||
@@ -694,7 +899,12 @@ export function AppShell({
|
|||||||
const openTab = useCallback(function openTab(
|
const openTab = useCallback(function openTab(
|
||||||
host: Host,
|
host: Host,
|
||||||
type: TabType,
|
type: TabType,
|
||||||
restore?: { instanceId: string; restoredSessionId: string | null },
|
restore?: {
|
||||||
|
instanceId: string;
|
||||||
|
restoredSessionId: string | null;
|
||||||
|
savedLabel?: string;
|
||||||
|
initialFilePath?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||||
const instanceId =
|
const instanceId =
|
||||||
@@ -707,7 +917,34 @@ export function AppShell({
|
|||||||
if (ref) terminalRefs.current.set(tabId, ref);
|
if (ref) terminalRefs.current.set(tabId, ref);
|
||||||
|
|
||||||
let finalLabel = host.name;
|
let finalLabel = host.name;
|
||||||
|
const savedLabel = restore?.savedLabel;
|
||||||
|
const initialFilePath = restore?.initialFilePath;
|
||||||
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||||
|
const isCustomLabel =
|
||||||
|
savedLabel != null &&
|
||||||
|
savedLabel !== host.name &&
|
||||||
|
!/^.+ \(\d+\)$/.test(savedLabel);
|
||||||
|
|
||||||
setTabs((prev) => {
|
setTabs((prev) => {
|
||||||
|
if (isCustomLabel && savedLabel) {
|
||||||
|
finalLabel = savedLabel;
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: tabId,
|
||||||
|
instanceId,
|
||||||
|
type,
|
||||||
|
label: finalLabel,
|
||||||
|
customLabel: finalLabel,
|
||||||
|
host,
|
||||||
|
openedAt,
|
||||||
|
terminalRef: ref,
|
||||||
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
initialFilePath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const same = prev.filter(
|
const same = prev.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||||
@@ -734,6 +971,7 @@ export function AppShell({
|
|||||||
openedAt,
|
openedAt,
|
||||||
terminalRef: ref,
|
terminalRef: ref,
|
||||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
initialFilePath,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -789,6 +1027,9 @@ export function AppShell({
|
|||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
} else if (pendingEvent === "host-manager:show-credentials") {
|
||||||
|
setSidebarOpen(true);
|
||||||
|
setRailView("credentials");
|
||||||
} else {
|
} else {
|
||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
setRailView("hosts");
|
setRailView("hosts");
|
||||||
@@ -919,6 +1160,18 @@ export function AppShell({
|
|||||||
doCloseTab(id);
|
doCloseTab(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renameTab(tabId: string, newLabel: string) {
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const tab = tabs.find((t) => t.id === tabId);
|
||||||
|
if (tab?.instanceId) {
|
||||||
|
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function splitTabQuick(tabId: string, mode: SplitMode) {
|
function splitTabQuick(tabId: string, mode: SplitMode) {
|
||||||
setSplitMode(mode);
|
setSplitMode(mode);
|
||||||
setPaneTabIds(() => {
|
setPaneTabIds(() => {
|
||||||
@@ -1182,6 +1435,7 @@ export function AppShell({
|
|||||||
openTab(host, record.tabType as TabType, {
|
openTab(host, record.tabType as TabType, {
|
||||||
instanceId: record.id,
|
instanceId: record.id,
|
||||||
restoredSessionId: effectiveSessionId,
|
restoredSessionId: effectiveSessionId,
|
||||||
|
savedLabel: record.label,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
openSingletonTab(record.tabType as TabType);
|
openSingletonTab(record.tabType as TabType);
|
||||||
@@ -1193,6 +1447,8 @@ export function AppShell({
|
|||||||
prev.filter((r) => r.id !== recordId),
|
prev.filter((r) => r.id !== recordId),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onRenameTab={renameTab}
|
||||||
|
onReorderTabs={setTabs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1208,6 +1464,7 @@ export function AppShell({
|
|||||||
<UserProfilePanel
|
<UserProfilePanel
|
||||||
username={username}
|
username={username}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
|
onChangeServer={onChangeServer}
|
||||||
userPrefs={userPrefs}
|
userPrefs={userPrefs}
|
||||||
onPrefsChange={setUserPrefs}
|
onPrefsChange={setUserPrefs}
|
||||||
/>
|
/>
|
||||||
@@ -1264,8 +1521,6 @@ export function AppShell({
|
|||||||
splitMode={splitMode}
|
splitMode={splitMode}
|
||||||
username={username}
|
username={username}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
profileDropdownOpen={profileDropdownOpen}
|
|
||||||
onProfileDropdownChange={setProfileDropdownOpen}
|
|
||||||
onRailClick={handleRailClick}
|
onRailClick={handleRailClick}
|
||||||
onOpenTab={openSingletonTab}
|
onOpenTab={openSingletonTab}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
@@ -1334,6 +1589,7 @@ export function AppShell({
|
|||||||
onSplitTab={splitTabQuick}
|
onSplitTab={splitTabQuick}
|
||||||
onAddToSplit={addTabToSplit}
|
onAddToSplit={addTabToSplit}
|
||||||
onRemoveFromSplit={removeTabFromSplit}
|
onRemoveFromSplit={removeTabFromSplit}
|
||||||
|
onRenameTab={renameTab}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||||
@@ -1388,6 +1644,17 @@ export function AppShell({
|
|||||||
openTab,
|
openTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
inPane || activeInline,
|
inPane || activeInline,
|
||||||
|
(host, filePath) =>
|
||||||
|
openTab(host, "files", {
|
||||||
|
instanceId:
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
restoredSessionId: null,
|
||||||
|
initialFilePath: filePath,
|
||||||
|
}),
|
||||||
|
(host, path) => openTab(host, "files"),
|
||||||
|
renameTab,
|
||||||
),
|
),
|
||||||
tabNode,
|
tabNode,
|
||||||
tab.id,
|
tab.id,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { authApi, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
|
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||||
|
|
||||||
|
export type AcmeSettings = {
|
||||||
|
enabled: boolean;
|
||||||
|
domain: string;
|
||||||
|
email: string;
|
||||||
|
challengeType: AcmeChallengeType;
|
||||||
|
cloudflareToken: string;
|
||||||
|
lastIssuedAt: string | null;
|
||||||
|
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||||
|
certExpiresAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAcmeSslSettings(): Promise<AcmeSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/acme-ssl-settings");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch ACME SSL settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAcmeSslSettings(
|
||||||
|
settings: Partial<
|
||||||
|
Omit<AcmeSettings, "certStatus" | "certExpiresAt" | "lastIssuedAt">
|
||||||
|
>,
|
||||||
|
): Promise<AcmeSettings> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/acme-ssl-settings", settings);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update ACME SSL settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestAcmeCertificate(): Promise<
|
||||||
|
AcmeSettings & { success: boolean }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/users/acme-ssl-request", {});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "request ACME certificate");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,3 +81,56 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
|
|||||||
throw handleApiError(error, "reset recent activity");
|
throw handleApiError(error, "reset recent activity");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceLink {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
order: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceLinks(): Promise<ServiceLink[]> {
|
||||||
|
try {
|
||||||
|
const response = await dashboardApi.get("/service-links");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch service links");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServiceLink(
|
||||||
|
label: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<ServiceLink> {
|
||||||
|
try {
|
||||||
|
const response = await dashboardApi.post("/service-links", { label, url });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "create service link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServiceLink(
|
||||||
|
id: number,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await dashboardApi.delete(`/service-links/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "delete service link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceLink(
|
||||||
|
id: number,
|
||||||
|
updates: { label?: string; url?: string },
|
||||||
|
): Promise<ServiceLink> {
|
||||||
|
try {
|
||||||
|
const response = await dashboardApi.put(`/service-links/${id}`, updates);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update service link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface UserPreferences {
|
|||||||
disableUpdateCheck?: boolean | null;
|
disableUpdateCheck?: boolean | null;
|
||||||
confirmTabClose?: boolean | null;
|
confirmTabClose?: boolean | null;
|
||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
|
compactHostView?: boolean | null;
|
||||||
|
statusColorScheme?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
|
|||||||
@@ -146,3 +146,43 @@ export async function updateGuacamoleSettings(settings: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
// HOST DEFAULTS SETTINGS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type HostDefaults = {
|
||||||
|
useSocks5?: boolean;
|
||||||
|
socks5Host?: string;
|
||||||
|
socks5Port?: number;
|
||||||
|
socks5Username?: string;
|
||||||
|
socks5Password?: string;
|
||||||
|
credentialId?: number | null;
|
||||||
|
metricsEnabled?: boolean;
|
||||||
|
statusCheckEnabled?: boolean;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
theme?: string;
|
||||||
|
cursorStyle?: string;
|
||||||
|
cursorBlink?: boolean;
|
||||||
|
enableSessionLogging?: boolean;
|
||||||
|
enableCommandHistory?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getHostDefaults(): Promise<HostDefaults> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/host-defaults");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch host defaults");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHostDefaults(
|
||||||
|
defaults: HostDefaults,
|
||||||
|
): Promise<HostDefaults> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/host-defaults", defaults);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update host defaults");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,3 +181,52 @@ export async function reorderSnippets(
|
|||||||
throw handleApiError(error, "reorder snippets");
|
throw handleApiError(error, "reorder snippets");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SnippetExportData {
|
||||||
|
snippets: Array<{
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
description?: string | null;
|
||||||
|
folder?: string | null;
|
||||||
|
order?: number;
|
||||||
|
hostFilter?: string | null;
|
||||||
|
}>;
|
||||||
|
folders: Array<{
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSnippets(): Promise<SnippetExportData> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/snippets/export");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "export snippets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSnippets(
|
||||||
|
data: SnippetExportData,
|
||||||
|
overwrite: boolean,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
snippetsImported: number;
|
||||||
|
snippetsSkipped: number;
|
||||||
|
snippetsUpdated: number;
|
||||||
|
foldersImported: number;
|
||||||
|
foldersSkipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/snippets/bulk-import", {
|
||||||
|
...data,
|
||||||
|
overwrite,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "import snippets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ export async function connectSSH(
|
|||||||
},
|
},
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/connect", {
|
const response = await fileManagerApi.post(
|
||||||
sessionId,
|
"/ssh/connect",
|
||||||
...config,
|
{ sessionId, ...config },
|
||||||
});
|
{ timeout: 120000 },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (
|
if (
|
||||||
@@ -344,18 +345,20 @@ export async function uploadSSHFile(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
path: string,
|
path: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
content: string,
|
file: File,
|
||||||
hostId?: number,
|
hostId?: number,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/uploadFile", {
|
const form = new FormData();
|
||||||
sessionId,
|
form.append("sessionId", sessionId);
|
||||||
path,
|
form.append("path", path);
|
||||||
fileName,
|
if (hostId !== undefined) form.append("hostId", String(hostId));
|
||||||
content,
|
if (userId !== undefined) form.append("userId", userId);
|
||||||
hostId,
|
form.append("file", file, fileName);
|
||||||
userId,
|
|
||||||
|
const response = await fileManagerApi.post("/ssh/uploadFileStream", form, {
|
||||||
|
timeout: 0,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -370,12 +373,16 @@ export async function downloadSSHFile(
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await fileManagerApi.post("/ssh/downloadFile", {
|
const response = await fileManagerApi.post(
|
||||||
|
"/ssh/downloadFile",
|
||||||
|
{
|
||||||
sessionId,
|
sessionId,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
hostId,
|
hostId,
|
||||||
userId,
|
userId,
|
||||||
});
|
},
|
||||||
|
{ timeout: 0 },
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "download SSH file");
|
handleApiError(error, "download SSH file");
|
||||||
@@ -389,7 +396,7 @@ export async function downloadSSHFileStream(
|
|||||||
const response = await fileManagerApi.post(
|
const response = await fileManagerApi.post(
|
||||||
"/ssh/downloadFileStream",
|
"/ssh/downloadFileStream",
|
||||||
{ sessionId, path: filePath },
|
{ sessionId, path: filePath },
|
||||||
{ responseType: "blob" },
|
{ responseType: "blob", timeout: 0 },
|
||||||
);
|
);
|
||||||
const blob = response.data as Blob;
|
const blob = response.data as Blob;
|
||||||
const fileName = filePath.split("/").pop() || "download";
|
const fileName = filePath.split("/").pop() || "download";
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ export async function bulkImportSSHHosts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importSSHConfigHosts(
|
||||||
|
content: string,
|
||||||
|
overwrite = false,
|
||||||
|
): Promise<{
|
||||||
|
message: string;
|
||||||
|
success: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await sshHostApi.post("/ssh-config-import", {
|
||||||
|
content,
|
||||||
|
overwrite,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "import SSH config hosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function discoverProxmoxGuests(
|
export async function discoverProxmoxGuests(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<ProxmoxDiscoverResult> {
|
): Promise<ProxmoxDiscoverResult> {
|
||||||
|
|||||||
@@ -196,6 +196,30 @@ export async function updateOidcAutoProvision(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOidcSilentLoginDefault(): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/oidc-silent-login-default");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "get OIDC silent login default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOidcSilentLoginDefault(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/oidc-silent-login-default", {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update OIDC silent login default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePasswordLoginAllowed(
|
export async function updatePasswordLoginAllowed(
|
||||||
allowed: boolean,
|
allowed: boolean,
|
||||||
): Promise<{ allowed: boolean }> {
|
): Promise<{ allowed: boolean }> {
|
||||||
|
|||||||
+59
-2
@@ -34,6 +34,7 @@ import {
|
|||||||
isElectron,
|
isElectron,
|
||||||
getEmbeddedServerStatus,
|
getEmbeddedServerStatus,
|
||||||
getCurrentToken,
|
getCurrentToken,
|
||||||
|
getOidcSilentLoginDefault,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||||
import type { SSOProviderPublic } from "@/types/index";
|
import type { SSOProviderPublic } from "@/types/index";
|
||||||
@@ -256,6 +257,9 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [ldapUsername, setLdapUsername] = useState("");
|
const [ldapUsername, setLdapUsername] = useState("");
|
||||||
const [ldapPassword, setLdapPassword] = useState("");
|
const [ldapPassword, setLdapPassword] = useState("");
|
||||||
const silentSigninHandledRef = useRef(false);
|
const silentSigninHandledRef = useRef(false);
|
||||||
|
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||||
|
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||||
|
useState(false);
|
||||||
const [firstUser, setFirstUser] = useState(false);
|
const [firstUser, setFirstUser] = useState(false);
|
||||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||||
@@ -288,6 +292,10 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
.then((providers) => setSsoProviders(providers || []))
|
.then((providers) => setSsoProviders(providers || []))
|
||||||
.catch(() => setSsoProviders([]))
|
.catch(() => setSsoProviders([]))
|
||||||
.finally(() => setSsoProvidersLoaded(true));
|
.finally(() => setSsoProvidersLoaded(true));
|
||||||
|
getOidcSilentLoginDefault()
|
||||||
|
.then((res) => setOidcSilentLoginDefault(res.enabled))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setOidcSilentLoginDefaultLoaded(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -312,6 +320,18 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
|
const forceShow = localStorage.getItem("termix_show_server_config");
|
||||||
|
if (forceShow === "true") {
|
||||||
|
localStorage.removeItem("termix_show_server_config");
|
||||||
|
try {
|
||||||
|
const config = await getServerConfig();
|
||||||
|
setCurrentServerUrl(config?.serverUrl || "");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setShowServerConfig(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [config, status] = await Promise.all([
|
const [config, status] = await Promise.all([
|
||||||
getServerConfig(),
|
getServerConfig(),
|
||||||
@@ -732,6 +752,29 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const loadingKey = providerId ?? -1;
|
const loadingKey = providerId ?? -1;
|
||||||
setProviderLoading((prev) => ({ ...prev, [loadingKey]: true }));
|
setProviderLoading((prev) => ({ ...prev, [loadingKey]: true }));
|
||||||
try {
|
try {
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
// Inside the Electron iframe: delegate OIDC to the parent window so
|
||||||
|
// the system browser opens instead of navigating the iframe (which
|
||||||
|
// would break captcha stages like Cloudflare Turnstile).
|
||||||
|
const callbackPort = 17832 + Math.floor(Math.random() * 100);
|
||||||
|
const authResponse = await getOIDCAuthorizeUrl(
|
||||||
|
rememberMe,
|
||||||
|
callbackPort,
|
||||||
|
providerId,
|
||||||
|
);
|
||||||
|
const { auth_url: authUrl } = authResponse;
|
||||||
|
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: "OIDC_SYSTEM_BROWSER_AUTH",
|
||||||
|
source: "oidc_request",
|
||||||
|
authUrl,
|
||||||
|
callbackPort,
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
const electronAPI = (
|
const electronAPI = (
|
||||||
window as unknown as {
|
window as unknown as {
|
||||||
@@ -834,14 +877,19 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
if (!oidcSilentLoginDefaultLoaded) return;
|
||||||
|
|
||||||
|
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
||||||
|
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
||||||
|
|
||||||
|
if (urlTriggered) {
|
||||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{},
|
{},
|
||||||
document.title,
|
document.title,
|
||||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
silentSigninHandledRef.current = true;
|
silentSigninHandledRef.current = true;
|
||||||
|
|
||||||
@@ -853,8 +901,17 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlTriggered) {
|
||||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
}
|
||||||
|
}, [
|
||||||
|
handleOIDCLogin,
|
||||||
|
ssoProvidersLoaded,
|
||||||
|
ssoProviders,
|
||||||
|
t,
|
||||||
|
oidcSilentLoginDefault,
|
||||||
|
oidcSilentLoginDefaultLoaded,
|
||||||
|
]);
|
||||||
|
|
||||||
// Electron server config / webview auth success screens
|
// Electron server config / webview auth success screens
|
||||||
if (isElectron() && !isInElectronWebView()) {
|
if (isElectron() && !isInElectronWebView()) {
|
||||||
|
|||||||
@@ -63,13 +63,43 @@ export function ElectronLoginForm({
|
|||||||
try {
|
try {
|
||||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||||
if (!event.data || typeof event.data !== "object") return;
|
if (!event.data || typeof event.data !== "object") return;
|
||||||
const { type, platform, source, token } = event.data;
|
const { type, platform, source, token, authUrl, callbackPort } =
|
||||||
|
event.data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
type === "AUTH_SUCCESS" &&
|
type === "AUTH_SUCCESS" &&
|
||||||
platform === "desktop" &&
|
platform === "desktop" &&
|
||||||
AUTH_MESSAGE_SOURCES.has(source)
|
AUTH_MESSAGE_SOURCES.has(source)
|
||||||
) {
|
) {
|
||||||
await handleAuthSuccess(token ?? null);
|
await handleAuthSuccess(token ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDC login requested from inside the iframe — open the system browser
|
||||||
|
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||||
|
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||||
|
const electronAPI = (
|
||||||
|
window as unknown as {
|
||||||
|
electronAPI?: {
|
||||||
|
oidcSystemBrowserAuth?: (
|
||||||
|
url: string,
|
||||||
|
port: number,
|
||||||
|
) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).electronAPI;
|
||||||
|
if (!electronAPI?.oidcSystemBrowserAuth) return;
|
||||||
|
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||||
|
authUrl,
|
||||||
|
callbackPort,
|
||||||
|
);
|
||||||
|
if (result.success && result.token) {
|
||||||
|
await handleAuthSuccess(result.token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/button.tsx";
|
import { Button } from "@/components/button.tsx";
|
||||||
import { Input } from "@/components/input.tsx";
|
import { Input } from "@/components/input.tsx";
|
||||||
import { Label } from "@/components/label.tsx";
|
import { Label } from "@/components/label.tsx";
|
||||||
@@ -12,7 +12,34 @@ import {
|
|||||||
setEmbeddedMode,
|
setEmbeddedMode,
|
||||||
type ServerConfig,
|
type ServerConfig,
|
||||||
} from "@/main-axios.ts";
|
} from "@/main-axios.ts";
|
||||||
import { Server, Monitor, Loader2 } from "lucide-react";
|
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
|
||||||
|
|
||||||
|
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||||
|
const MAX_SAVED_URLS = 5;
|
||||||
|
|
||||||
|
function getSavedUrls(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVED_URLS_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSavedUrl(url: string) {
|
||||||
|
const urls = getSavedUrls().filter((u) => u !== url);
|
||||||
|
urls.unshift(url);
|
||||||
|
localStorage.setItem(
|
||||||
|
SAVED_URLS_KEY,
|
||||||
|
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSavedUrl(url: string) {
|
||||||
|
const urls = getSavedUrls().filter((u) => u !== url);
|
||||||
|
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||||
|
}
|
||||||
|
|
||||||
interface ServerConfigProps {
|
interface ServerConfigProps {
|
||||||
onServerConfigured: (serverUrl: string) => void;
|
onServerConfigured: (serverUrl: string) => void;
|
||||||
@@ -36,12 +63,31 @@ export function ElectronServerConfig({
|
|||||||
const [embeddedAvailable, setEmbeddedAvailable] = useState<boolean | null>(
|
const [embeddedAvailable, setEmbeddedAvailable] = useState<boolean | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [savedUrls, setSavedUrls] = useState<string[]>([]);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadServerConfig();
|
loadServerConfig();
|
||||||
checkEmbeddedBackend();
|
checkEmbeddedBackend();
|
||||||
|
setSavedUrls(getSavedUrls());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropdownOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
const loadServerConfig = async () => {
|
const loadServerConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const config = await getServerConfig();
|
const config = await getServerConfig();
|
||||||
@@ -151,6 +197,8 @@ export function ElectronServerConfig({
|
|||||||
const success = await saveServerConfig(config);
|
const success = await saveServerConfig(config);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
addSavedUrl(normalizedUrl);
|
||||||
|
setSavedUrls(getSavedUrls());
|
||||||
onServerConfigured(normalizedUrl);
|
onServerConfigured(normalizedUrl);
|
||||||
} else {
|
} else {
|
||||||
setError(t("serverConfig.saveFailed"));
|
setError(t("serverConfig.saveFailed"));
|
||||||
@@ -220,6 +268,7 @@ export function ElectronServerConfig({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Input
|
<Input
|
||||||
id="server-url"
|
id="server-url"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -227,7 +276,62 @@ export function ElectronServerConfig({
|
|||||||
value={serverUrl}
|
value={serverUrl}
|
||||||
onChange={(e) => handleUrlChange(e.target.value)}
|
onChange={(e) => handleUrlChange(e.target.value)}
|
||||||
disabled={loading || embeddedLoading}
|
disabled={loading || embeddedLoading}
|
||||||
|
className={savedUrls.length > 0 ? "pr-9" : ""}
|
||||||
|
onFocus={() => {
|
||||||
|
if (savedUrls.length > 0) setDropdownOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{savedUrls.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setDropdownOpen((o) => !o)}
|
||||||
|
disabled={loading || embeddedLoading}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label={t("serverConfig.savedServers")}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{dropdownOpen && savedUrls.length > 0 && (
|
||||||
|
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
|
||||||
|
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
|
||||||
|
{t("serverConfig.savedServers")}
|
||||||
|
</p>
|
||||||
|
{savedUrls.map((url) => (
|
||||||
|
<div
|
||||||
|
key={url}
|
||||||
|
className="flex items-center justify-between group hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
|
||||||
|
onClick={() => {
|
||||||
|
handleUrlChange(url);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||||
|
title={t("serverConfig.removeServer")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeSavedUrl(url);
|
||||||
|
const updated = getSavedUrls();
|
||||||
|
setSavedUrls(updated);
|
||||||
|
if (updated.length === 0) setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{serverUrl.trim().startsWith("https://") && (
|
{serverUrl.trim().startsWith("https://") && (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
isElectron,
|
isElectron,
|
||||||
getEmbeddedServerStatus,
|
getEmbeddedServerStatus,
|
||||||
getCurrentToken,
|
getCurrentToken,
|
||||||
|
getOidcSilentLoginDefault,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||||
import type { SSOProviderPublic } from "@/types/index";
|
import type { SSOProviderPublic } from "@/types/index";
|
||||||
@@ -129,6 +130,9 @@ export function Auth({
|
|||||||
const [ldapPassword, setLdapPassword] = useState("");
|
const [ldapPassword, setLdapPassword] = useState("");
|
||||||
const [ldapLoading, setLdapLoading] = useState(false);
|
const [ldapLoading, setLdapLoading] = useState(false);
|
||||||
const silentSigninHandledRef = useRef(false);
|
const silentSigninHandledRef = useRef(false);
|
||||||
|
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||||
|
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const [resetStep, setResetStep] = useState<
|
const [resetStep, setResetStep] = useState<
|
||||||
"initiate" | "verify" | "newPassword"
|
"initiate" | "verify" | "newPassword"
|
||||||
@@ -258,6 +262,17 @@ export function Auth({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOidcSilentLoginDefault()
|
||||||
|
.then((res) => {
|
||||||
|
setOidcSilentLoginDefault(res.enabled);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
setOidcSilentLoginDefaultLoaded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showServerConfig) {
|
if (showServerConfig) {
|
||||||
return;
|
return;
|
||||||
@@ -683,8 +698,19 @@ export function Auth({
|
|||||||
setLdapLoading(true);
|
setLdapLoading(true);
|
||||||
try {
|
try {
|
||||||
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
|
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
|
||||||
const userInfo = await getUserInfo();
|
const meRes = await getUserInfo();
|
||||||
onLogin(userInfo);
|
setInternalLoggedIn(true);
|
||||||
|
setLoggedIn(true);
|
||||||
|
setIsAdmin(!!meRes.is_admin);
|
||||||
|
setUsername(meRes.username || null);
|
||||||
|
setUserId(meRes.userId || null);
|
||||||
|
setDbError(null);
|
||||||
|
onAuthSuccess({
|
||||||
|
isAdmin: !!meRes.is_admin,
|
||||||
|
username: meRes.username || null,
|
||||||
|
userId: meRes.userId || null,
|
||||||
|
});
|
||||||
|
toast.success(t("messages.loginSuccess"));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as {
|
const error = err as {
|
||||||
response?: { data?: { error?: string } };
|
response?: { data?: { error?: string } };
|
||||||
@@ -699,19 +725,35 @@ export function Auth({
|
|||||||
setLdapLoading(false);
|
setLdapLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
|
[
|
||||||
|
ldapUsername,
|
||||||
|
ldapPassword,
|
||||||
|
rememberMe,
|
||||||
|
onAuthSuccess,
|
||||||
|
setLoggedIn,
|
||||||
|
setIsAdmin,
|
||||||
|
setUsername,
|
||||||
|
setUserId,
|
||||||
|
setDbError,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
if (!oidcSilentLoginDefaultLoaded) return;
|
||||||
|
|
||||||
|
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
||||||
|
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
||||||
|
|
||||||
|
if (urlTriggered) {
|
||||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
{},
|
{},
|
||||||
document.title,
|
document.title,
|
||||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
silentSigninHandledRef.current = true;
|
silentSigninHandledRef.current = true;
|
||||||
const oidcProvider = ssoProviders.find(
|
const oidcProvider = ssoProviders.find(
|
||||||
@@ -728,8 +770,17 @@ export function Auth({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (urlTriggered) {
|
||||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
}
|
||||||
|
}, [
|
||||||
|
handleOIDCLogin,
|
||||||
|
ssoProvidersLoaded,
|
||||||
|
ssoProviders,
|
||||||
|
t,
|
||||||
|
oidcSilentLoginDefault,
|
||||||
|
oidcSilentLoginDefaultLoaded,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@@ -1515,6 +1566,14 @@ export function Auth({
|
|||||||
className="flex flex-col gap-5"
|
className="flex flex-col gap-5"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
|
{!passwordLoginAllowed &&
|
||||||
|
!firstUser &&
|
||||||
|
tab === "login" ? (
|
||||||
|
<p className="text-center text-muted-foreground text-sm">
|
||||||
|
{t("auth.passwordLoginDisabledDesc")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="username">
|
<Label htmlFor="username">
|
||||||
{t("common.username")}
|
{t("common.username")}
|
||||||
@@ -1540,7 +1599,9 @@ export function Auth({
|
|||||||
required
|
required
|
||||||
className="h-11 text-base"
|
className="h-11 text-base"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setPassword(e.target.value)
|
||||||
|
}
|
||||||
disabled={loading || loggedIn}
|
disabled={loading || loggedIn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1605,9 +1666,14 @@ export function Auth({
|
|||||||
{t("auth.resetPasswordButton")}
|
{t("auth.resetPasswordButton")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{ssoProviders.length > 0 && !isElectron() && (
|
{ssoProviders.length > 0 && !isElectron() && (
|
||||||
<div className="flex flex-col gap-3 pt-2">
|
<div className="flex flex-col gap-3 pt-2">
|
||||||
|
{(passwordLoginAllowed ||
|
||||||
|
firstUser ||
|
||||||
|
tab === "signup") && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 border-t border-border" />
|
<div className="flex-1 border-t border-border" />
|
||||||
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
||||||
@@ -1615,6 +1681,7 @@ export function Auth({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-border" />
|
<div className="flex-1 border-t border-border" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{ssoProviders.map((provider) => {
|
{ssoProviders.map((provider) => {
|
||||||
if (provider.type === "ldap") {
|
if (provider.type === "ldap") {
|
||||||
const isExpanded =
|
const isExpanded =
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface ProxmoxDiscoverDialogProps {
|
|||||||
preselectedHostId?: number;
|
preselectedHostId?: number;
|
||||||
/** Credential to use for imported hosts */
|
/** Credential to use for imported hosts */
|
||||||
defaultCredentialId?: number | null;
|
defaultCredentialId?: number | null;
|
||||||
|
/** Auth type to use for imported hosts */
|
||||||
|
defaultAuthType?: string;
|
||||||
/** Username from the default credential */
|
/** Username from the default credential */
|
||||||
defaultUsername?: string;
|
defaultUsername?: string;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,7 @@ export function ProxmoxDiscoverDialog({
|
|||||||
onHostsChanged,
|
onHostsChanged,
|
||||||
preselectedHostId,
|
preselectedHostId,
|
||||||
defaultCredentialId,
|
defaultCredentialId,
|
||||||
|
defaultAuthType,
|
||||||
defaultUsername,
|
defaultUsername,
|
||||||
}: ProxmoxDiscoverDialogProps) {
|
}: ProxmoxDiscoverDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -115,6 +118,8 @@ export function ProxmoxDiscoverDialog({
|
|||||||
try {
|
try {
|
||||||
// Prefer explicitly configured credential, then fall back to the host's own credential
|
// Prefer explicitly configured credential, then fall back to the host's own credential
|
||||||
const credId = defaultCredentialId ?? discoveredCredentialId;
|
const credId = defaultCredentialId ?? discoveredCredentialId;
|
||||||
|
const resolvedAuthType =
|
||||||
|
defaultAuthType ?? (credId != null ? "credential" : "password");
|
||||||
|
|
||||||
const toImport = guests
|
const toImport = guests
|
||||||
.filter((g) => selected.has(g.vmid))
|
.filter((g) => selected.has(g.vmid))
|
||||||
@@ -124,13 +129,13 @@ export function ProxmoxDiscoverDialog({
|
|||||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||||
username: defaultUsername ?? "root",
|
username: defaultUsername ?? "root",
|
||||||
folder: importFolder,
|
folder: importFolder,
|
||||||
...(credId != null
|
authType: resolvedAuthType,
|
||||||
|
...(resolvedAuthType === "credential" && credId != null
|
||||||
? {
|
? {
|
||||||
authType: "credential" as const,
|
|
||||||
credentialId: credId,
|
credentialId: credId,
|
||||||
overrideCredentialUsername: true,
|
overrideCredentialUsername: true,
|
||||||
}
|
}
|
||||||
: { authType: "password" as const }),
|
: {}),
|
||||||
enableTerminal: g.connectionType !== "rdp",
|
enableTerminal: g.connectionType !== "rdp",
|
||||||
enableFileManager: g.connectionType !== "rdp",
|
enableFileManager: g.connectionType !== "rdp",
|
||||||
enableTunnel: g.connectionType !== "rdp",
|
enableTunnel: g.connectionType !== "rdp",
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Separator } from "@/components/separator";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Database,
|
Database,
|
||||||
|
ExternalLink,
|
||||||
GripHorizontal,
|
GripHorizontal,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Link,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Network,
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -37,10 +39,21 @@ import {
|
|||||||
registerMetricsViewer,
|
registerMetricsViewer,
|
||||||
sendMetricsHeartbeat,
|
sendMetricsHeartbeat,
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
|
getServiceLinks,
|
||||||
|
createServiceLink,
|
||||||
|
deleteServiceLink,
|
||||||
|
} from "@/main-axios";
|
||||||
|
import type {
|
||||||
|
RecentActivityItem,
|
||||||
|
SSHHostWithStatus,
|
||||||
|
ServiceLink,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||||
|
import {
|
||||||
|
useStatusColorScheme,
|
||||||
|
getStatusClasses,
|
||||||
|
} from "@/hooks/use-status-color-scheme";
|
||||||
|
|
||||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||||
return {
|
return {
|
||||||
@@ -66,7 +79,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
|||||||
macAddress: h.macAddress,
|
macAddress: h.macAddress,
|
||||||
enableTerminal: h.enableTerminal ?? true,
|
enableTerminal: h.enableTerminal ?? true,
|
||||||
enableTunnel: h.enableTunnel ?? false,
|
enableTunnel: h.enableTunnel ?? false,
|
||||||
enableFileManager: h.enableFileManager ?? false,
|
enableFileManager: h.enableFileManager ?? true,
|
||||||
enableDocker: h.enableDocker ?? false,
|
enableDocker: h.enableDocker ?? false,
|
||||||
enableSsh: h.connectionType === "ssh" || !h.connectionType,
|
enableSsh: h.connectionType === "ssh" || !h.connectionType,
|
||||||
enableRdp: h.connectionType === "rdp",
|
enableRdp: h.connectionType === "rdp",
|
||||||
@@ -205,35 +218,48 @@ function CountersBarCard({
|
|||||||
hosts,
|
hosts,
|
||||||
credentialCount,
|
credentialCount,
|
||||||
activeTunnelCount,
|
activeTunnelCount,
|
||||||
|
onOpenSingletonTab,
|
||||||
}: {
|
}: {
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
credentialCount: number;
|
credentialCount: number;
|
||||||
activeTunnelCount: number;
|
activeTunnelCount: number;
|
||||||
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
||||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
<button
|
||||||
|
onClick={() => onOpenSingletonTab("host-manager")}
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||||
|
>
|
||||||
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="text-base font-bold">{hosts.length}</span>
|
<span className="text-base font-bold">{hosts.length}</span>
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
{t("dashboard.totalHosts")}
|
{t("dashboard.totalHosts")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||||
|
>
|
||||||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="text-base font-bold">{credentialCount}</span>
|
<span className="text-base font-bold">{credentialCount}</span>
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
{t("dashboard.totalCredentials")}
|
{t("dashboard.totalCredentials")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
<button
|
||||||
|
onClick={() => onOpenSingletonTab("tunnel")}
|
||||||
|
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||||
|
>
|
||||||
<Network className="size-3.5 text-muted-foreground shrink-0" />
|
<Network className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="text-base font-bold">{activeTunnelCount}</span>
|
<span className="text-base font-bold">{activeTunnelCount}</span>
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
{t("dashboardTab.activeTunnels")}
|
{t("dashboardTab.activeTunnels")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -365,16 +391,48 @@ function QuickActionsCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MetricBar({ label, value }: { label: string; value: number }) {
|
||||||
|
const color =
|
||||||
|
value >= 90
|
||||||
|
? "bg-red-500"
|
||||||
|
: value >= 70
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: "bg-accent-brand";
|
||||||
|
const textColor =
|
||||||
|
value >= 90
|
||||||
|
? "text-red-400"
|
||||||
|
: value >= 70
|
||||||
|
? "text-yellow-400"
|
||||||
|
: "text-accent-brand";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 w-16">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||||
|
<span className={`text-[10px] font-bold ${textColor}`}>
|
||||||
|
{value.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-0.5 bg-muted w-full">
|
||||||
|
<div className={`h-full ${color}`} style={{ width: `${value}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HostStatusCard({
|
function HostStatusCard({
|
||||||
hosts,
|
hosts,
|
||||||
hostMetrics,
|
hostMetrics,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
}: {
|
}: {
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
hostMetrics: Map<
|
||||||
|
string,
|
||||||
|
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||||
|
>;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const statusScheme = useStatusColorScheme();
|
||||||
const online = hosts.filter((h) => h.online).length;
|
const online = hosts.filter((h) => h.online).length;
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||||
@@ -399,19 +457,23 @@ function HostStatusCard({
|
|||||||
const metrics = hostMetrics.get(host.id);
|
const metrics = hostMetrics.get(host.id);
|
||||||
const cpu = metrics?.cpu ?? null;
|
const cpu = metrics?.cpu ?? null;
|
||||||
const ram = metrics?.ram ?? null;
|
const ram = metrics?.ram ?? null;
|
||||||
const hasMetrics = cpu !== null || ram !== null;
|
const disk = metrics?.disk ?? null;
|
||||||
|
const hasMetrics = cpu !== null || ram !== null || disk !== null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => onOpenTab(host, "host-metrics")}
|
onClick={() => onOpenTab(host, "host-metrics")}
|
||||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<span
|
||||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs font-semibold">{host.name}</span>
|
<span className="text-xs font-semibold">{host.name}</span>
|
||||||
|
<ExternalLink className="size-2.5 text-muted-foreground/0 group-hover/row:text-muted-foreground/60 transition-colors shrink-0" />
|
||||||
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground font-mono">
|
<span className="text-[10px] text-muted-foreground font-mono">
|
||||||
{host.ip}
|
{host.ip}
|
||||||
</span>
|
</span>
|
||||||
@@ -421,40 +483,13 @@ function HostStatusCard({
|
|||||||
{host.online && hasMetrics ? (
|
{host.online && hasMetrics ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{cpu !== null && (
|
{cpu !== null && (
|
||||||
<div className="flex flex-col gap-0.5 w-16">
|
<MetricBar label={t("dashboard.cpu")} value={cpu} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{t("dashboard.cpu")}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-accent-brand">
|
|
||||||
{cpu.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-0.5 bg-muted w-full">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent-brand"
|
|
||||||
style={{ width: `${cpu}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{ram !== null && (
|
{ram !== null && (
|
||||||
<div className="flex flex-col gap-0.5 w-16">
|
<MetricBar label={t("dashboard.ram")} value={ram} />
|
||||||
<div className="flex items-center justify-between">
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground">
|
{disk !== null && (
|
||||||
{t("dashboard.ram")}
|
<MetricBar label={t("dashboardTab.disk")} value={disk} />
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-accent-brand">
|
|
||||||
{ram.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-0.5 bg-muted w-full">
|
|
||||||
<div
|
|
||||||
className="h-full bg-accent-brand"
|
|
||||||
style={{ width: `${ram}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -465,10 +500,13 @@ function HostStatusCard({
|
|||||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||||
—
|
—
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${host.online ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-border text-muted-foreground"}`}
|
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge")}`}
|
||||||
>
|
>
|
||||||
{host.online
|
{host.online
|
||||||
? t("dashboardTab.online")
|
? t("dashboardTab.online")
|
||||||
@@ -495,6 +533,7 @@ function RecentActivityCard({
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const statusScheme = useStatusColorScheme();
|
||||||
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
|
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
|
||||||
terminal: <Terminal className="size-2.5" />,
|
terminal: <Terminal className="size-2.5" />,
|
||||||
file_manager: <Server className="size-2.5" />,
|
file_manager: <Server className="size-2.5" />,
|
||||||
@@ -570,7 +609,7 @@ function RecentActivityCard({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`size-1.5 rounded-full shrink-0 ${host?.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot")}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-semibold truncate max-w-24">
|
<span className="text-xs font-semibold truncate max-w-24">
|
||||||
@@ -593,6 +632,124 @@ function RecentActivityCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ServiceLinksCard({
|
||||||
|
links,
|
||||||
|
onAdd,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
links: ServiceLink[];
|
||||||
|
onAdd: (label: string, url: string) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [urlError, setUrlError] = useState(false);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
let valid = true;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
if (!valid) {
|
||||||
|
setUrlError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUrlError(false);
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await onAdd(label.trim(), url.trim());
|
||||||
|
setLabel("");
|
||||||
|
setUrl("");
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||||
|
<Link className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
|
{t("dashboardTab.serviceLinksTitle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col overflow-auto flex-1">
|
||||||
|
{links.length === 0 && (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-4">
|
||||||
|
{t("dashboardTab.serviceLinksEmpty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{links.map((link) => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 group/link"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="flex items-center gap-2 min-w-0 flex-1 hover:text-accent-brand transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-xs font-semibold truncate">
|
||||||
|
{link.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{link.url}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(link.id)}
|
||||||
|
className="ml-2 opacity-0 group-hover/link:opacity-100 transition-opacity size-5 flex items-center justify-center hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 border-t border-border shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")}
|
||||||
|
className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
setUrlError(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||||
|
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white h-6 px-2 shrink-0"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!label.trim() || !url.trim() || adding}
|
||||||
|
>
|
||||||
|
{t("dashboardTab.serviceLinksAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{urlError && (
|
||||||
|
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||||
|
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CardItem({
|
function CardItem({
|
||||||
@@ -617,6 +774,9 @@ function CardItem({
|
|||||||
activity,
|
activity,
|
||||||
onClearActivity,
|
onClearActivity,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
serviceLinks,
|
||||||
|
onAddServiceLink,
|
||||||
|
onDeleteServiceLink,
|
||||||
}: {
|
}: {
|
||||||
slot: CardSlot;
|
slot: CardSlot;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
@@ -629,7 +789,10 @@ function CardItem({
|
|||||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
hostMetrics: Map<
|
||||||
|
string,
|
||||||
|
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||||
|
>;
|
||||||
uptimeFormatted: string;
|
uptimeFormatted: string;
|
||||||
versionText: string;
|
versionText: string;
|
||||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||||
@@ -639,6 +802,9 @@ function CardItem({
|
|||||||
activity: RecentActivityItem[];
|
activity: RecentActivityItem[];
|
||||||
onClearActivity: () => void;
|
onClearActivity: () => void;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
serviceLinks: ServiceLink[];
|
||||||
|
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||||
|
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -704,6 +870,7 @@ function CardItem({
|
|||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
credentialCount={credentialCount}
|
credentialCount={credentialCount}
|
||||||
activeTunnelCount={activeTunnelCount}
|
activeTunnelCount={activeTunnelCount}
|
||||||
|
onOpenSingletonTab={onOpenSingletonTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "quick_actions" && (
|
{slot.id === "quick_actions" && (
|
||||||
@@ -735,6 +902,13 @@ function CardItem({
|
|||||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{slot.id === "service_links" && (
|
||||||
|
<ServiceLinksCard
|
||||||
|
links={serviceLinks}
|
||||||
|
onAdd={onAddServiceLink}
|
||||||
|
onDelete={onDeleteServiceLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editMode && !isFlex && (
|
{editMode && !isFlex && (
|
||||||
<div
|
<div
|
||||||
@@ -831,7 +1005,10 @@ type PanelColumnProps = {
|
|||||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
hostMetrics: Map<
|
||||||
|
string,
|
||||||
|
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||||
|
>;
|
||||||
uptimeFormatted: string;
|
uptimeFormatted: string;
|
||||||
versionText: string;
|
versionText: string;
|
||||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||||
@@ -842,6 +1019,9 @@ type PanelColumnProps = {
|
|||||||
onClearActivity: () => void;
|
onClearActivity: () => void;
|
||||||
cardLabels: Record<DashboardCardId, string>;
|
cardLabels: Record<DashboardCardId, string>;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
serviceLinks: ServiceLink[];
|
||||||
|
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||||
|
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PanelColumn({
|
function PanelColumn({
|
||||||
@@ -869,6 +1049,9 @@ function PanelColumn({
|
|||||||
onClearActivity,
|
onClearActivity,
|
||||||
cardLabels,
|
cardLabels,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
serviceLinks,
|
||||||
|
onAddServiceLink,
|
||||||
|
onDeleteServiceLink,
|
||||||
}: PanelColumnProps) {
|
}: PanelColumnProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
||||||
@@ -921,6 +1104,9 @@ function PanelColumn({
|
|||||||
activity={activity}
|
activity={activity}
|
||||||
onClearActivity={onClearActivity}
|
onClearActivity={onClearActivity}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
|
serviceLinks={serviceLinks}
|
||||||
|
onAddServiceLink={onAddServiceLink}
|
||||||
|
onDeleteServiceLink={onDeleteServiceLink}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1028,7 +1214,7 @@ export function DashboardTab({
|
|||||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||||
const [hostMetrics, setHostMetrics] = useState<
|
const [hostMetrics, setHostMetrics] = useState<
|
||||||
Map<string, { cpu: number | null; ram: number | null }>
|
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||||
|
|
||||||
@@ -1066,6 +1252,7 @@ export function DashboardTab({
|
|||||||
id: host.id,
|
id: host.id,
|
||||||
cpu: metrics.cpu?.percent ?? null,
|
cpu: metrics.cpu?.percent ?? null,
|
||||||
ram: metrics.memory?.percent ?? null,
|
ram: metrics.memory?.percent ?? null,
|
||||||
|
disk: metrics.disk?.percent ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -1074,9 +1261,12 @@ export function DashboardTab({
|
|||||||
);
|
);
|
||||||
|
|
||||||
viewerSessionsRef.current = newSessions;
|
viewerSessionsRef.current = newSessions;
|
||||||
const map = new Map<string, { cpu: number | null; ram: number | null }>();
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||||
|
>();
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram });
|
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram, disk: r.disk });
|
||||||
}
|
}
|
||||||
setHostMetrics(map);
|
setHostMetrics(map);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1169,6 +1359,24 @@ export function DashboardTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [serviceLinks, setServiceLinks] = useState<ServiceLink[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getServiceLinks()
|
||||||
|
.then(setServiceLinks)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddServiceLink = async (label: string, url: string) => {
|
||||||
|
const created = await createServiceLink(label, url);
|
||||||
|
setServiceLinks((prev) => [...prev, created]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteServiceLink = async (id: number) => {
|
||||||
|
await deleteServiceLink(id);
|
||||||
|
setServiceLinks((prev) => prev.filter((l) => l.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const todayLabel = new Date().toLocaleDateString(i18n.language, {
|
const todayLabel = new Date().toLocaleDateString(i18n.language, {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
month: "long",
|
month: "long",
|
||||||
@@ -1192,6 +1400,7 @@ export function DashboardTab({
|
|||||||
host_status: t("dashboardTab.hostStatus"),
|
host_status: t("dashboardTab.hostStatus"),
|
||||||
recent_activity: t("dashboard.recentActivity"),
|
recent_activity: t("dashboard.recentActivity"),
|
||||||
network_graph: t("dashboard.networkGraph"),
|
network_graph: t("dashboard.networkGraph"),
|
||||||
|
service_links: t("dashboard.serviceLinks"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const onColumnDividerMouseDown = useCallback(
|
const onColumnDividerMouseDown = useCallback(
|
||||||
@@ -1264,6 +1473,8 @@ export function DashboardTab({
|
|||||||
? 350
|
? 350
|
||||||
: id === "host_status" || id === "recent_activity"
|
: id === "host_status" || id === "recent_activity"
|
||||||
? null
|
? null
|
||||||
|
: id === "service_links"
|
||||||
|
? 200
|
||||||
: 150;
|
: 150;
|
||||||
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
||||||
});
|
});
|
||||||
@@ -1299,6 +1510,9 @@ export function DashboardTab({
|
|||||||
onOpenTab,
|
onOpenTab,
|
||||||
cardLabels,
|
cardLabels,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
serviceLinks,
|
||||||
|
onAddServiceLink: handleAddServiceLink,
|
||||||
|
onDeleteServiceLink: handleDeleteServiceLink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -1393,6 +1607,7 @@ export function DashboardTab({
|
|||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
credentialCount={credentialCount}
|
credentialCount={credentialCount}
|
||||||
activeTunnelCount={activeTunnelCount}
|
activeTunnelCount={activeTunnelCount}
|
||||||
|
onOpenSingletonTab={onOpenSingletonTab}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "quick_actions" && (
|
{slot.id === "quick_actions" && (
|
||||||
@@ -1424,6 +1639,13 @@ export function DashboardTab({
|
|||||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{slot.id === "service_links" && (
|
||||||
|
<ServiceLinksCard
|
||||||
|
links={serviceLinks}
|
||||||
|
onAdd={handleAddServiceLink}
|
||||||
|
onDelete={handleDeleteServiceLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -128,11 +128,17 @@ function buildNodeSvg(
|
|||||||
): string {
|
): string {
|
||||||
const isOnline = status === "online";
|
const isOnline = status === "online";
|
||||||
const isOffline = status === "offline";
|
const isOffline = status === "offline";
|
||||||
const statusColor = isOnline
|
const useRealColors = localStorage.getItem("statusColorScheme") === "status";
|
||||||
|
let statusColor: string;
|
||||||
|
if (isOnline) {
|
||||||
|
statusColor = useRealColors
|
||||||
? "rgb(16,185,129)"
|
? "rgb(16,185,129)"
|
||||||
: isOffline
|
: resolveCssVar("--accent-brand", "rgb(16,185,129)");
|
||||||
? "rgb(239,68,68)"
|
} else if (isOffline) {
|
||||||
: "rgb(100,116,139)";
|
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
|
||||||
|
} else {
|
||||||
|
statusColor = "rgb(100,116,139)";
|
||||||
|
}
|
||||||
|
|
||||||
const bg = resolveCssVar("--card", "#1e1e20");
|
const bg = resolveCssVar("--card", "#1e1e20");
|
||||||
const border = resolveCssVar("--border", "#2a2a2c");
|
const border = resolveCssVar("--border", "#2a2a2c");
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function LogViewer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 bg-[#111210] border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
<div className="flex-1 bg-muted border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((line, i) => {
|
filteredLogs.map((line, i) => {
|
||||||
const tsEnd = line.indexOf(" ", 1);
|
const tsEnd = line.indexOf(" ", 1);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
||||||
|
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
|
||||||
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
||||||
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
||||||
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||||
@@ -78,7 +79,12 @@ import type {
|
|||||||
} from "./file-manager-types.ts";
|
} from "./file-manager-types.ts";
|
||||||
import { formatFileSize } from "./file-manager-utils.ts";
|
import { formatFileSize } from "./file-manager-utils.ts";
|
||||||
|
|
||||||
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
function FileManagerContent({
|
||||||
|
initialHost,
|
||||||
|
initialFilePath,
|
||||||
|
initialPath,
|
||||||
|
onClose,
|
||||||
|
}: FileManagerProps) {
|
||||||
const { openWindow } = useWindowManager();
|
const { openWindow } = useWindowManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const formatTransferMetrics = useMemo(
|
const formatTransferMetrics = useMemo(
|
||||||
@@ -94,10 +100,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
|
|
||||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||||
const [currentPath, setCurrentPath] = useState(
|
const [currentPath, setCurrentPath] = useState(
|
||||||
initialHost?.defaultPath || "/",
|
initialPath || initialHost?.defaultPath || "/",
|
||||||
);
|
);
|
||||||
const [navHistory, setNavHistory] = useState<string[]>([
|
const [navHistory, setNavHistory] = useState<string[]>([
|
||||||
initialHost?.defaultPath || "/",
|
initialPath || initialHost?.defaultPath || "/",
|
||||||
]);
|
]);
|
||||||
const [navIndex, setNavIndex] = useState(0);
|
const [navIndex, setNavIndex] = useState(0);
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
@@ -133,6 +139,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
const [authDialogReason, setAuthDialogReason] = useState<
|
const [authDialogReason, setAuthDialogReason] = useState<
|
||||||
"no_keyboard" | "auth_failed" | "timeout"
|
"no_keyboard" | "auth_failed" | "timeout"
|
||||||
>("no_keyboard");
|
>("no_keyboard");
|
||||||
|
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||||
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
||||||
@@ -267,6 +274,60 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
};
|
};
|
||||||
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
||||||
|
|
||||||
|
const initialFileOpenedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sshSessionId || !initialFilePath || initialFileOpenedRef.current)
|
||||||
|
return;
|
||||||
|
initialFileOpenedRef.current = true;
|
||||||
|
|
||||||
|
const fileName = initialFilePath.split("/").pop() || initialFilePath;
|
||||||
|
const fileDir =
|
||||||
|
initialFilePath.lastIndexOf("/") > 0
|
||||||
|
? initialFilePath.substring(0, initialFilePath.lastIndexOf("/"))
|
||||||
|
: "/";
|
||||||
|
|
||||||
|
setCurrentPath(fileDir);
|
||||||
|
|
||||||
|
const file: FileItem = {
|
||||||
|
name: fileName,
|
||||||
|
path: initialFilePath,
|
||||||
|
type: "file",
|
||||||
|
};
|
||||||
|
|
||||||
|
const windowCount = Date.now() % 10;
|
||||||
|
const offsetX = Math.min(
|
||||||
|
120 + windowCount * 30,
|
||||||
|
Math.max(0, window.innerWidth - 820),
|
||||||
|
);
|
||||||
|
const offsetY = Math.min(
|
||||||
|
120 + windowCount * 30,
|
||||||
|
Math.max(0, window.innerHeight - 620),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createWindowComponent = (windowId: string) => (
|
||||||
|
<FileWindow
|
||||||
|
windowId={windowId}
|
||||||
|
file={file}
|
||||||
|
sshSessionId={sshSessionId}
|
||||||
|
sshHost={currentHost}
|
||||||
|
initialX={offsetX}
|
||||||
|
initialY={offsetY}
|
||||||
|
onFileNotFound={handleFileNotFound}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
openWindow({
|
||||||
|
title: fileName,
|
||||||
|
x: offsetX,
|
||||||
|
y: offsetY,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
isMaximized: false,
|
||||||
|
isMinimized: false,
|
||||||
|
component: createWindowComponent,
|
||||||
|
});
|
||||||
|
}, [sshSessionId, initialFilePath]);
|
||||||
|
|
||||||
const initialLoadDoneRef = useRef(false);
|
const initialLoadDoneRef = useRef(false);
|
||||||
const lastPathChangeRef = useRef<string>("");
|
const lastPathChangeRef = useRef<string>("");
|
||||||
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -416,6 +477,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result?.status === "passphrase_required") {
|
||||||
|
setShowPassphraseDialog(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSshSessionId(sessionId);
|
setSshSessionId(sessionId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -839,24 +906,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
"/"
|
"/"
|
||||||
: currentPath;
|
: currentPath;
|
||||||
|
|
||||||
const fileContent = await new Promise<string>((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
resolve(reader.result.split(",")[1] || "");
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read file"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
await uploadSSHFile(
|
await uploadSSHFile(
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
uploadPath,
|
uploadPath,
|
||||||
file.name,
|
file.name,
|
||||||
fileContent,
|
file,
|
||||||
currentHost?.id,
|
currentHost?.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -896,26 +950,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
try {
|
try {
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
const fileContent = await new Promise<string>((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onerror = () => reject(reader.error);
|
|
||||||
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
const base64 = reader.result.split(",")[1] || "";
|
|
||||||
resolve(base64);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read file"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
await uploadSSHFile(
|
await uploadSSHFile(
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
currentPath,
|
currentPath,
|
||||||
file.name,
|
file.name,
|
||||||
fileContent,
|
file,
|
||||||
currentHost?.id,
|
currentHost?.id,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -1357,6 +1396,23 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCopyFolderLink(path: string) {
|
||||||
|
if (!currentHost?.id) return;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
view: "file-manager",
|
||||||
|
hostId: String(currentHost.id),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
const url = `${window.location.origin}?${params.toString()}`;
|
||||||
|
copyToClipboard(url).then((ok) => {
|
||||||
|
if (ok) {
|
||||||
|
toast.success(t("fileManager.folderLinkCopied"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("fileManager.failedToCopyFolderLink"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePasteFiles() {
|
async function handlePasteFiles() {
|
||||||
if (!clipboard || !sshSessionId) return;
|
if (!clipboard || !sshSessionId) return;
|
||||||
|
|
||||||
@@ -2120,6 +2176,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePassphraseSubmit(passphrase: string) {
|
||||||
|
if (!currentHost) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setShowPassphraseDialog(false);
|
||||||
|
|
||||||
|
const sessionId = currentHost.id.toString();
|
||||||
|
|
||||||
|
const result = await connectSSH(sessionId, {
|
||||||
|
hostId: currentHost.id,
|
||||||
|
ip: currentHost.ip,
|
||||||
|
port: currentHost.port,
|
||||||
|
username: currentHost.username,
|
||||||
|
sshKey: currentHost.key,
|
||||||
|
keyPassword: passphrase,
|
||||||
|
authType: "key",
|
||||||
|
credentialId: currentHost.credentialId,
|
||||||
|
userId: currentHost.userId,
|
||||||
|
jumpHosts: currentHost.jumpHosts,
|
||||||
|
useSocks5: currentHost.useSocks5,
|
||||||
|
socks5Host: currentHost.socks5Host,
|
||||||
|
socks5Port: currentHost.socks5Port,
|
||||||
|
socks5Username: currentHost.socks5Username,
|
||||||
|
socks5Password: currentHost.socks5Password,
|
||||||
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.status === "passphrase_required") {
|
||||||
|
setShowPassphraseDialog(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(t("fileManager.incorrectPassphrase"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.requires_totp) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
setTotpSessionId(sessionId);
|
||||||
|
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.status === "auth_required") {
|
||||||
|
setAuthDialogReason(result.reason || "auth_failed");
|
||||||
|
setShowAuthDialog(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSshSessionId(sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await listSSHFiles(sessionId, currentPath);
|
||||||
|
const files = Array.isArray(response)
|
||||||
|
? response
|
||||||
|
: response?.files || [];
|
||||||
|
setFiles(files);
|
||||||
|
clearSelection();
|
||||||
|
initialLoadDoneRef.current = true;
|
||||||
|
toast.success(t("fileManager.connectedSuccessfully"));
|
||||||
|
logFileManagerActivity();
|
||||||
|
} catch (dirError: unknown) {
|
||||||
|
console.error("Failed to load initial directory:", dirError);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("SSH connection with passphrase failed:", error);
|
||||||
|
setShowPassphraseDialog(true);
|
||||||
|
toast.error(t("fileManager.incorrectPassphrase"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePassphraseCancel() {
|
||||||
|
setShowPassphraseDialog(false);
|
||||||
|
if (onClose) onClose();
|
||||||
|
}
|
||||||
|
|
||||||
function generateUniqueName(
|
function generateUniqueName(
|
||||||
baseName: string,
|
baseName: string,
|
||||||
type: "file" | "directory",
|
type: "file" | "directory",
|
||||||
@@ -2749,6 +2884,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
onExtractArchive={handleExtractArchive}
|
onExtractArchive={handleExtractArchive}
|
||||||
onCompress={handleOpenCompressDialog}
|
onCompress={handleOpenCompressDialog}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
|
onCopyFolderLink={handleCopyFolderLink}
|
||||||
onTransferToHost={handleOpenTransferDialog}
|
onTransferToHost={handleOpenTransferDialog}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -2768,6 +2904,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentHost && (
|
||||||
|
<PassphraseDialog
|
||||||
|
isOpen={showPassphraseDialog}
|
||||||
|
onSubmit={handlePassphraseSubmit}
|
||||||
|
onCancel={handlePassphraseCancel}
|
||||||
|
hostInfo={{
|
||||||
|
ip: currentHost.ip,
|
||||||
|
port: currentHost.port,
|
||||||
|
username: currentHost.username,
|
||||||
|
name: currentHost.name,
|
||||||
|
}}
|
||||||
|
backgroundColor="var(--bg-canvas)"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FileManagerDialogs
|
<FileManagerDialogs
|
||||||
compressDialogFiles={compressDialogFiles}
|
compressDialogFiles={compressDialogFiles}
|
||||||
setCompressDialogFiles={setCompressDialogFiles}
|
setCompressDialogFiles={setCompressDialogFiles}
|
||||||
@@ -2805,10 +2956,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileManagerInner({ initialHost, onClose }: FileManagerProps) {
|
function FileManagerInner({
|
||||||
|
initialHost,
|
||||||
|
initialFilePath,
|
||||||
|
initialPath,
|
||||||
|
onClose,
|
||||||
|
}: FileManagerProps) {
|
||||||
return (
|
return (
|
||||||
<WindowManager>
|
<WindowManager>
|
||||||
<FileManagerContent initialHost={initialHost} onClose={onClose} />
|
<FileManagerContent
|
||||||
|
initialHost={initialHost}
|
||||||
|
initialFilePath={initialFilePath}
|
||||||
|
initialPath={initialPath}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
</WindowManager>
|
</WindowManager>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
|||||||
|
|
||||||
interface FileManagerAppProps {
|
interface FileManagerAppProps {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
|
initialPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
const FileManagerApp: React.FC<FileManagerAppProps> = ({
|
||||||
|
hostId,
|
||||||
|
initialPath,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<FullScreenAppWrapper hostId={hostId}>
|
<FullScreenAppWrapper hostId={hostId}>
|
||||||
@@ -39,6 +43,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
|||||||
<FileManager
|
<FileManager
|
||||||
embedded={true}
|
embedded={true}
|
||||||
initialHost={hostConfig}
|
initialHost={hostConfig}
|
||||||
|
initialPath={initialPath}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
|
Link,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
||||||
@@ -67,6 +68,7 @@ interface ContextMenuProps {
|
|||||||
onExtractArchive?: (file: FileItem) => void;
|
onExtractArchive?: (file: FileItem) => void;
|
||||||
onCompress?: (files: FileItem[]) => void;
|
onCompress?: (files: FileItem[]) => void;
|
||||||
onCopyPath?: (files: FileItem[]) => void;
|
onCopyPath?: (files: FileItem[]) => void;
|
||||||
|
onCopyFolderLink?: (path: string) => void;
|
||||||
onTransferToHost?: (files: FileItem[], move: boolean) => void;
|
onTransferToHost?: (files: FileItem[], move: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ export function FileManagerContextMenu({
|
|||||||
onExtractArchive,
|
onExtractArchive,
|
||||||
onCompress,
|
onCompress,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onCopyFolderLink,
|
||||||
onTransferToHost,
|
onTransferToHost,
|
||||||
}: ContextMenuProps) {
|
}: ContextMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -350,12 +353,22 @@ export function FileManagerContextMenu({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) {
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Link className="size-3.5" />,
|
||||||
|
label: t("fileManager.copyFolderLink"),
|
||||||
|
action: () => onCopyFolderLink(files[0].path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||||
(isSingleFile &&
|
(isSingleFile &&
|
||||||
files[0].type === "file" &&
|
files[0].type === "file" &&
|
||||||
(onPinFile || onUnpinFile)) ||
|
(onPinFile || onUnpinFile)) ||
|
||||||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
|
(isSingleFile &&
|
||||||
|
files[0].type === "directory" &&
|
||||||
|
(onAddShortcut || onCopyFolderLink))
|
||||||
) {
|
) {
|
||||||
menuItems.push({ separator: true } as MenuItem);
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
}
|
}
|
||||||
@@ -491,6 +504,15 @@ export function FileManagerContextMenu({
|
|||||||
shortcut: "Ctrl+V",
|
shortcut: "Ctrl+V",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onCopyFolderLink && currentPath) {
|
||||||
|
menuItems.push({ separator: true } as MenuItem);
|
||||||
|
menuItems.push({
|
||||||
|
icon: <Link className="size-3.5" />,
|
||||||
|
label: t("fileManager.copyCurrentFolderLink"),
|
||||||
|
action: () => onCopyFolderLink(currentPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||||
|
|||||||
@@ -173,8 +173,10 @@ export function FileManagerSidebar({
|
|||||||
try {
|
try {
|
||||||
const response = await listSSHFiles(sshSessionId, "/");
|
const response = await listSSHFiles(sshSessionId, "/");
|
||||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||||
const rootFolders = rootFiles.filter(
|
const rootFolders = rootFiles
|
||||||
(item: DirectoryItemData) => item.type === "directory",
|
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||||
@@ -232,8 +234,10 @@ export function FileManagerSidebar({
|
|||||||
try {
|
try {
|
||||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||||
const subFolders = subFiles.filter(
|
const subFolders = subFiles
|
||||||
(item: DirectoryItemData) => item.type === "directory",
|
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface CodeEditorProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
|
fontSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLanguageExtension(filename: string) {
|
function getLanguageExtension(filename: string) {
|
||||||
@@ -73,7 +74,7 @@ function getLanguageExtension(filename: string) {
|
|||||||
|
|
||||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||||
function CodeEditor(
|
function CodeEditor(
|
||||||
{ fileName, value, placeholder, onChange, onFocus, onBlur },
|
{ fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const editorRef = useRef<{ view?: EditorView } | null>(null);
|
const editorRef = useRef<{ view?: EditorView } | null>(null);
|
||||||
@@ -105,6 +106,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
|||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": {
|
"&": {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
},
|
},
|
||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
@@ -116,7 +118,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}, [fileName]);
|
}, [fileName, fontSize]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Search,
|
Search,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
SiJavascript,
|
SiJavascript,
|
||||||
@@ -85,6 +87,7 @@ interface FileViewerProps {
|
|||||||
savedContent?: string;
|
savedContent?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isEditable?: boolean;
|
isEditable?: boolean;
|
||||||
|
resetKey?: number;
|
||||||
onContentChange?: (content: string) => void;
|
onContentChange?: (content: string) => void;
|
||||||
onSave?: (content: string) => void;
|
onSave?: (content: string) => void;
|
||||||
onRevert?: () => void;
|
onRevert?: () => void;
|
||||||
@@ -265,6 +268,7 @@ export function FileViewer({
|
|||||||
savedContent = "",
|
savedContent = "",
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isEditable = false,
|
isEditable = false,
|
||||||
|
resetKey,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
onSave,
|
onSave,
|
||||||
onRevert,
|
onRevert,
|
||||||
@@ -280,8 +284,31 @@ export function FileViewer({
|
|||||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
||||||
const [editorFocused, setEditorFocused] = useState(false);
|
const [editorFocused, setEditorFocused] = useState(false);
|
||||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||||
|
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
|
||||||
|
const stored = localStorage.getItem("fileManagerEditorFontSize");
|
||||||
|
return stored ? parseInt(stored, 10) : 14;
|
||||||
|
});
|
||||||
const editorRef = useRef<CodeEditorHandle | null>(null);
|
const editorRef = useRef<CodeEditorHandle | null>(null);
|
||||||
|
|
||||||
|
const MIN_FONT_SIZE = 8;
|
||||||
|
const MAX_FONT_SIZE = 32;
|
||||||
|
|
||||||
|
const decreaseFontSize = () => {
|
||||||
|
setEditorFontSize((prev) => {
|
||||||
|
const next = Math.max(MIN_FONT_SIZE, prev - 1);
|
||||||
|
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const increaseFontSize = () => {
|
||||||
|
setEditorFontSize((prev) => {
|
||||||
|
const next = Math.min(MAX_FONT_SIZE, prev + 1);
|
||||||
|
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const fileTypeInfo = getFileType(file.name);
|
const fileTypeInfo = getFileType(file.name);
|
||||||
|
|
||||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||||
@@ -301,6 +328,9 @@ export function FileViewer({
|
|||||||
if (savedContent) {
|
if (savedContent) {
|
||||||
setOriginalContent(savedContent);
|
setOriginalContent(savedContent);
|
||||||
}
|
}
|
||||||
|
}, [file.name, file.path, resetKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
setHasChanges(content !== savedContent);
|
setHasChanges(content !== savedContent);
|
||||||
|
|
||||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||||
@@ -308,14 +338,7 @@ export function FileViewer({
|
|||||||
} else {
|
} else {
|
||||||
setShowLargeFileWarning(false);
|
setShowLargeFileWarning(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||||
content,
|
|
||||||
savedContent,
|
|
||||||
fileTypeInfo.type,
|
|
||||||
isLargeFile,
|
|
||||||
forceShowAsText,
|
|
||||||
file.name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleContentChange = (newContent: string) => {
|
const handleContentChange = (newContent: string) => {
|
||||||
setEditedContent(newContent);
|
setEditedContent(newContent);
|
||||||
@@ -417,6 +440,33 @@ export function FileViewer({
|
|||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isEditable && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={decreaseFontSize}
|
||||||
|
disabled={editorFontSize <= MIN_FONT_SIZE}
|
||||||
|
title={t("fileManager.decreaseFontSize")}
|
||||||
|
className="w-7 h-7 p-0"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground w-8 text-center select-none">
|
||||||
|
{editorFontSize}px
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={increaseFontSize}
|
||||||
|
disabled={editorFontSize >= MAX_FONT_SIZE}
|
||||||
|
title={t("fileManager.increaseFontSize")}
|
||||||
|
className="w-7 h-7 p-0"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -684,6 +734,7 @@ export function FileViewer({
|
|||||||
onFocus={() => setEditorFocused(true)}
|
onFocus={() => setEditorFocused(true)}
|
||||||
onBlur={() => setEditorFocused(false)}
|
onBlur={() => setEditorFocused(false)}
|
||||||
placeholder={t("fileManager.startTyping")}
|
placeholder={t("fileManager.startTyping")}
|
||||||
|
fontSize={editorFontSize}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ function isDisplayableText(str: string): boolean {
|
|||||||
(code >= 32 && code <= 126) ||
|
(code >= 32 && code <= 126) ||
|
||||||
code === 9 ||
|
code === 9 ||
|
||||||
code === 10 ||
|
code === 10 ||
|
||||||
code === 13
|
code === 13 ||
|
||||||
|
code >= 128
|
||||||
) {
|
) {
|
||||||
printable++;
|
printable++;
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,7 @@ export function FileWindow({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isEditable, setIsEditable] = useState(false);
|
const [isEditable, setIsEditable] = useState(false);
|
||||||
const [pendingContent, setPendingContent] = useState<string>("");
|
const [pendingContent, setPendingContent] = useState<string>("");
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
const [mediaDimensions, setMediaDimensions] = useState<
|
const [mediaDimensions, setMediaDimensions] = useState<
|
||||||
{ width: number; height: number } | undefined
|
{ width: number; height: number } | undefined
|
||||||
>();
|
>();
|
||||||
@@ -141,6 +143,7 @@ export function FileWindow({
|
|||||||
|
|
||||||
setContent(fileContent);
|
setContent(fileContent);
|
||||||
setPendingContent(fileContent);
|
setPendingContent(fileContent);
|
||||||
|
setResetKey((k) => k + 1);
|
||||||
|
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
const contentSize = new Blob([fileContent]).size;
|
const contentSize = new Blob([fileContent]).size;
|
||||||
@@ -268,6 +271,7 @@ export function FileWindow({
|
|||||||
const fileContent = response.content || "";
|
const fileContent = response.content || "";
|
||||||
setContent(fileContent);
|
setContent(fileContent);
|
||||||
setPendingContent("");
|
setPendingContent("");
|
||||||
|
setResetKey((k) => k + 1);
|
||||||
|
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
const contentSize = new Blob([fileContent]).size;
|
const contentSize = new Blob([fileContent]).size;
|
||||||
@@ -444,6 +448,7 @@ export function FileWindow({
|
|||||||
content={pendingContent || content}
|
content={pendingContent || content}
|
||||||
savedContent={content}
|
savedContent={content}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
resetKey={resetKey}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
isEditable={isEditable}
|
isEditable={isEditable}
|
||||||
onContentChange={handleContentChange}
|
onContentChange={handleContentChange}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { LogEntry } from "@/types/connection-log.ts";
|
|||||||
|
|
||||||
export interface FileManagerProps {
|
export interface FileManagerProps {
|
||||||
initialHost?: SSHHost | null;
|
initialHost?: SSHHost | null;
|
||||||
|
initialFilePath?: string;
|
||||||
|
initialPath?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getGuacamoleTokenFromHost,
|
getGuacamoleTokenFromHost,
|
||||||
getGuacdStatus,
|
getGuacdStatus,
|
||||||
getSSHHosts,
|
getSSHHosts,
|
||||||
|
logActivity,
|
||||||
} from "@/main-axios.ts";
|
} from "@/main-axios.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
@@ -76,6 +77,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
|||||||
<GuacamoleAppInner
|
<GuacamoleAppInner
|
||||||
hostId={parseInt(hostId, 10)}
|
hostId={parseInt(hostId, 10)}
|
||||||
hostConfig={hostConfig}
|
hostConfig={hostConfig}
|
||||||
|
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
protocol={protocol}
|
protocol={protocol}
|
||||||
/>
|
/>
|
||||||
@@ -85,6 +87,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
|||||||
interface GuacamoleAppInnerProps {
|
interface GuacamoleAppInnerProps {
|
||||||
hostId: number;
|
hostId: number;
|
||||||
hostConfig: Pick<SSHHost, "connectionType">;
|
hostConfig: Pick<SSHHost, "connectionType">;
|
||||||
|
hostName: string;
|
||||||
tabId?: string;
|
tabId?: string;
|
||||||
protocol?: "rdp" | "vnc" | "telnet";
|
protocol?: "rdp" | "vnc" | "telnet";
|
||||||
}
|
}
|
||||||
@@ -92,6 +95,7 @@ interface GuacamoleAppInnerProps {
|
|||||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||||
hostId,
|
hostId,
|
||||||
hostConfig,
|
hostConfig,
|
||||||
|
hostName,
|
||||||
tabId,
|
tabId,
|
||||||
protocol,
|
protocol,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -114,7 +118,13 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
|||||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result) setToken(result.token);
|
if (result) {
|
||||||
|
setToken(result.token);
|
||||||
|
const resolvedProtocol = (protocol ??
|
||||||
|
hostConfig.connectionType ??
|
||||||
|
"rdp") as "rdp" | "vnc" | "telnet";
|
||||||
|
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||||
}, [hostId, protocol, retryCount, t]);
|
}, [hostId, protocol, retryCount, t]);
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
const h = Math.round(rect.height);
|
const h = Math.round(rect.height);
|
||||||
if (w > 0 && h > 0) {
|
if (w > 0 && h > 0) {
|
||||||
clientRef.current.sendSize(w, h);
|
clientRef.current.sendSize(w, h);
|
||||||
|
rescaleDisplay(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Input } from "@/components/input";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/sheet";
|
||||||
|
import type { TerminalHandle } from "./terminal-types";
|
||||||
|
|
||||||
|
interface MobileTerminalKeyboardProps {
|
||||||
|
terminalRef: React.RefObject<TerminalHandle | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CTRL_MAP: Record<string, string> = {
|
||||||
|
a: "\x01",
|
||||||
|
c: "\x03",
|
||||||
|
d: "\x04",
|
||||||
|
k: "\x0B",
|
||||||
|
l: "\x0C",
|
||||||
|
r: "\x12",
|
||||||
|
u: "\x15",
|
||||||
|
w: "\x17",
|
||||||
|
z: "\x1A",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_QUICK_KEYS = [
|
||||||
|
"/",
|
||||||
|
"|",
|
||||||
|
"~",
|
||||||
|
"-",
|
||||||
|
"_",
|
||||||
|
"#",
|
||||||
|
"\\",
|
||||||
|
'"',
|
||||||
|
"'",
|
||||||
|
";",
|
||||||
|
":",
|
||||||
|
"!",
|
||||||
|
"&",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LS_KEY = "termix:mobileQuickKeys";
|
||||||
|
|
||||||
|
function loadQuickKeys(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return DEFAULT_QUICK_KEYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQuickKeys(keys: string[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(keys));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared button styles
|
||||||
|
const KEY_BASE =
|
||||||
|
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
|
||||||
|
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
|
||||||
|
const KEY_ACTIVE =
|
||||||
|
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
|
||||||
|
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
|
||||||
|
const KEY_SM = "h-9 w-9";
|
||||||
|
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||||
|
|
||||||
|
// --- QuickKeysSheet ---
|
||||||
|
|
||||||
|
interface QuickKeysSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
quickKeys: string[];
|
||||||
|
onUpdateKeys: (keys: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickKeysSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
quickKeys,
|
||||||
|
onUpdateKeys,
|
||||||
|
}: QuickKeysSheetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [newSymbol, setNewSymbol] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setNewSymbol("");
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function addKey() {
|
||||||
|
const sym = newSymbol.trim();
|
||||||
|
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
|
||||||
|
setNewSymbol("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUpdateKeys([...quickKeys, sym]);
|
||||||
|
setNewSymbol("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKey(index: number) {
|
||||||
|
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDefaults() {
|
||||||
|
onUpdateKeys(DEFAULT_QUICK_KEYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-h-[70vh] flex flex-col border-border"
|
||||||
|
>
|
||||||
|
<SheetHeader className="flex-row items-start justify-between pr-8">
|
||||||
|
<div>
|
||||||
|
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{t("mobileKeyboard.quickKeysDesc")}
|
||||||
|
</SheetDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={resetDefaults}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3 mr-1" />
|
||||||
|
{t("mobileKeyboard.resetDefaults")}
|
||||||
|
</Button>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{quickKeys.map((sym, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{sym}</span>
|
||||||
|
<button
|
||||||
|
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||||
|
onClick={() => removeKey(i)}
|
||||||
|
title={t("mobileKeyboard.removeQuickKey")}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 px-4 py-2 border-t border-border">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={newSymbol}
|
||||||
|
onChange={(e) => setNewSymbol(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") addKey();
|
||||||
|
}}
|
||||||
|
maxLength={8}
|
||||||
|
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
|
||||||
|
className="h-9 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 shrink-0"
|
||||||
|
onClick={addKey}
|
||||||
|
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" />
|
||||||
|
{t("mobileKeyboard.addQuickKey")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="pt-0">
|
||||||
|
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("mobileKeyboard.done")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CtrlPanel ---
|
||||||
|
|
||||||
|
interface CtrlPanelProps {
|
||||||
|
onSend: (letter: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CtrlPanel({ onSend }: CtrlPanelProps) {
|
||||||
|
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
|
||||||
|
{CTRL_KEYS.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend(k);
|
||||||
|
}}
|
||||||
|
title={`Ctrl+${k.toUpperCase()}`}
|
||||||
|
>
|
||||||
|
^{k.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MobileTerminalKeyboard ---
|
||||||
|
|
||||||
|
export function MobileTerminalKeyboard({
|
||||||
|
terminalRef,
|
||||||
|
}: MobileTerminalKeyboardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [ctrlActive, setCtrlActive] = useState(false);
|
||||||
|
const [shiftActive, setShiftActive] = useState(false);
|
||||||
|
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
|
||||||
|
function send(seq: string) {
|
||||||
|
terminalRef.current?.sendInput?.(seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCtrl() {
|
||||||
|
setCtrlActive((v) => !v);
|
||||||
|
setShiftActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShift() {
|
||||||
|
setShiftActive((v) => !v);
|
||||||
|
setCtrlActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
|
||||||
|
if (shiftActive) {
|
||||||
|
send(shiftSeq);
|
||||||
|
setShiftActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appMode =
|
||||||
|
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
|
||||||
|
send(appMode ? appSeq : normalSeq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTab() {
|
||||||
|
send(shiftActive ? "\x1b[Z" : "\t");
|
||||||
|
setShiftActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCtrlKey(letter: string) {
|
||||||
|
const seq = CTRL_MAP[letter];
|
||||||
|
if (seq) send(seq);
|
||||||
|
setCtrlActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickKeys(next: string[]) {
|
||||||
|
setQuickKeys(next);
|
||||||
|
saveQuickKeys(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
|
||||||
|
{/* Row 1 — special keys */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
|
||||||
|
{/* ESC */}
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.esc")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.esc")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tab / back-tab */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
KEY_BASE,
|
||||||
|
KEY_NORMAL,
|
||||||
|
KEY_MD,
|
||||||
|
shiftActive && "ring-1 ring-accent-brand/50",
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTab();
|
||||||
|
}}
|
||||||
|
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
|
||||||
|
>
|
||||||
|
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={SEP} />
|
||||||
|
|
||||||
|
{/* Ctrl */}
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCtrl();
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.ctrl")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.ctrl")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Shift */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
KEY_BASE,
|
||||||
|
KEY_MD,
|
||||||
|
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
|
||||||
|
)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShift();
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.shift")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.shift")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={SEP} />
|
||||||
|
|
||||||
|
{/* Arrow keys */}
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.arrowUp")}
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.arrowDown")}
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.arrowLeft")}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.arrowRight")}
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={SEP} />
|
||||||
|
|
||||||
|
{/* Home / End */}
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b[H");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.home")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.home")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b[F");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.end")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.end")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={SEP} />
|
||||||
|
|
||||||
|
{/* PgUp / PgDn / Del */}
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b[5~");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.pageUp")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.pageUp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b[6~");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.pageDown")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.pageDown")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send("\x1b[3~");
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.delete")}
|
||||||
|
>
|
||||||
|
{t("mobileKeyboard.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ctrl combos panel */}
|
||||||
|
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
|
||||||
|
|
||||||
|
{/* Row 2 — quick keys */}
|
||||||
|
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
|
||||||
|
{quickKeys.map((sym, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
send(sym);
|
||||||
|
}}
|
||||||
|
title={sym}
|
||||||
|
>
|
||||||
|
{sym}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="ml-auto shrink-0">
|
||||||
|
<button
|
||||||
|
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
title={t("mobileKeyboard.editQuickKeys")}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<QuickKeysSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={setSheetOpen}
|
||||||
|
quickKeys={quickKeys}
|
||||||
|
onUpdateKeys={updateQuickKeys}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@/lib/terminal-themes.ts";
|
} from "@/lib/terminal-themes.ts";
|
||||||
import "./terminal-global-styles.ts";
|
import "./terminal-global-styles.ts";
|
||||||
import { useTheme } from "@/components/theme-provider.tsx";
|
import { useTheme } from "@/components/theme-provider.tsx";
|
||||||
|
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||||
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
||||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||||
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||||
@@ -75,6 +76,7 @@ interface SSHTerminalProps {
|
|||||||
/** Attach to this tmux session right after connecting (tmux monitor). */
|
/** Attach to this tmux session right after connecting (tmux monitor). */
|
||||||
tmuxAttachSession?: string;
|
tmuxAttachSession?: string;
|
||||||
onOpenFileManager?: (path?: string) => void;
|
onOpenFileManager?: (path?: string) => void;
|
||||||
|
onOpenFileInEditor?: (filePath: string) => void;
|
||||||
previewTheme?: string | null;
|
previewTheme?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,10 +87,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
isVisible,
|
isVisible,
|
||||||
splitScreen = false,
|
splitScreen = false,
|
||||||
onClose,
|
onClose,
|
||||||
|
onTitleChange,
|
||||||
initialPath,
|
initialPath,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
tmuxAttachSession,
|
tmuxAttachSession,
|
||||||
onOpenFileManager,
|
onOpenFileManager,
|
||||||
|
onOpenFileInEditor,
|
||||||
previewTheme,
|
previewTheme,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -114,7 +118,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
const activeTheme = previewTheme || config.theme;
|
const activeTheme = previewTheme || config.theme;
|
||||||
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
||||||
const backgroundColor = themeColors.background;
|
const backgroundImage = config.backgroundImage || "";
|
||||||
|
const backgroundImageOpacity = config.backgroundImageOpacity ?? 0.15;
|
||||||
|
const backgroundColor = backgroundImage
|
||||||
|
? "transparent"
|
||||||
|
: themeColors.background;
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const webSocketRef = useRef<WebSocket | null>(null);
|
const webSocketRef = useRef<WebSocket | null>(null);
|
||||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -176,6 +184,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const pendingRestoredSessionIdRef = useRef<string | null>(
|
const pendingRestoredSessionIdRef = useRef<string | null>(
|
||||||
hostConfig.restoredSessionId ?? null,
|
hostConfig.restoredSessionId ?? null,
|
||||||
);
|
);
|
||||||
|
const [linkClickDialog, setLinkClickDialog] = useState<{
|
||||||
|
url: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -655,6 +667,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
isFittingRef.current = false;
|
isFittingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
focus: () => terminal?.focus(),
|
||||||
sendInput: (data: string) => {
|
sendInput: (data: string) => {
|
||||||
if (webSocketRef.current?.readyState === 1) {
|
if (webSocketRef.current?.readyState === 1) {
|
||||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||||
@@ -673,6 +686,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
refresh: () => hardRefresh(),
|
refresh: () => hardRefresh(),
|
||||||
|
getApplicationCursorKeysMode: () =>
|
||||||
|
terminal?.modes?.applicationCursorKeysMode ?? false,
|
||||||
openFileManager: () => {
|
openFileManager: () => {
|
||||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||||
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
|
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
|
||||||
@@ -941,6 +956,24 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
|
if (data === "\r" || data === "\n") {
|
||||||
|
const currentCmd = getCurrentCommand().trim();
|
||||||
|
const termixMatch = currentCmd.match(/^termix\s+(.+)$/);
|
||||||
|
if (termixMatch && onOpenFileInEditor) {
|
||||||
|
const filePath = termixMatch[1].trim();
|
||||||
|
trackInput(data);
|
||||||
|
terminal.write("\r\n");
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "open_file_in_editor",
|
||||||
|
path: filePath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
trackInput(data);
|
trackInput(data);
|
||||||
ws.send(JSON.stringify({ type: "input", data }));
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
});
|
});
|
||||||
@@ -970,6 +1003,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
if (msg.type === "data") {
|
if (msg.type === "data") {
|
||||||
if (typeof msg.data === "string") {
|
if (typeof msg.data === "string") {
|
||||||
|
if (showAutocompleteRef.current) {
|
||||||
|
showAutocompleteRef.current = false;
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
setAutocompleteSuggestions([]);
|
||||||
|
currentAutocompleteCommand.current = "";
|
||||||
|
}
|
||||||
|
|
||||||
const syntaxHighlightingEnabled =
|
const syntaxHighlightingEnabled =
|
||||||
hostConfig.terminalConfig?.syntaxHighlighting !== false;
|
hostConfig.terminalConfig?.syntaxHighlighting !== false;
|
||||||
|
|
||||||
@@ -982,7 +1022,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
terminal.write(outputData);
|
terminal.write(outputData);
|
||||||
const sudoPasswordPattern =
|
const sudoPasswordPattern =
|
||||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required|password for [^\n]*:\s*$|Password:\s*$)/i;
|
/(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$)/im;
|
||||||
|
// Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+)
|
||||||
|
// emit colored prompts with embedded escape sequences that break the regex.
|
||||||
|
const strippedData = msg.data.replace(
|
||||||
|
/\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g,
|
||||||
|
"",
|
||||||
|
);
|
||||||
const hasSudoPw =
|
const hasSudoPw =
|
||||||
hostConfig.terminalConfig?.sudoPassword ||
|
hostConfig.terminalConfig?.sudoPassword ||
|
||||||
hostConfig.password ||
|
hostConfig.password ||
|
||||||
@@ -990,7 +1036,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
hostConfig.hasPassword;
|
hostConfig.hasPassword;
|
||||||
if (
|
if (
|
||||||
config.sudoPasswordAutoFill &&
|
config.sudoPasswordAutoFill &&
|
||||||
sudoPasswordPattern.test(msg.data) &&
|
sudoPasswordPattern.test(strippedData) &&
|
||||||
hasSudoPw &&
|
hasSudoPw &&
|
||||||
!sudoPromptShownRef.current
|
!sudoPromptShownRef.current
|
||||||
) {
|
) {
|
||||||
@@ -1386,6 +1432,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
} else if (msg.type === "cwd") {
|
} else if (msg.type === "cwd") {
|
||||||
onOpenFileManager?.(msg.path as string);
|
onOpenFileManager?.(msg.path as string);
|
||||||
|
} else if (msg.type === "open_file_in_editor") {
|
||||||
|
onOpenFileInEditor?.(msg.path as string);
|
||||||
} else if (msg.type === "passphrase_required") {
|
} else if (msg.type === "passphrase_required") {
|
||||||
setShowPassphraseDialog(true);
|
setShowPassphraseDialog(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
@@ -1792,7 +1840,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
| "both";
|
| "both";
|
||||||
|
|
||||||
terminal.options.theme = {
|
terminal.options.theme = {
|
||||||
background: themeColors.background,
|
background: config.backgroundImage
|
||||||
|
? "transparent"
|
||||||
|
: themeColors.background,
|
||||||
foreground: themeColors.foreground,
|
foreground: themeColors.foreground,
|
||||||
cursor: themeColors.cursor,
|
cursor: themeColors.cursor,
|
||||||
cursorAccent: themeColors.cursorAccent,
|
cursorAccent: themeColors.cursorAccent,
|
||||||
@@ -1850,7 +1900,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
fontFamily,
|
fontFamily,
|
||||||
allowTransparency: true, // MUST be set before open()
|
allowTransparency: true, // MUST be set before open()
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
macOptionIsMeta: false,
|
macOptionIsMeta: true,
|
||||||
macOptionClickForcesSelection: false,
|
macOptionClickForcesSelection: false,
|
||||||
rightClickSelectsWord: config.rightClickSelectsWord,
|
rightClickSelectsWord: config.rightClickSelectsWord,
|
||||||
fastScrollSensitivity: config.fastScrollSensitivity,
|
fastScrollSensitivity: config.fastScrollSensitivity,
|
||||||
@@ -1860,7 +1910,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
lineHeight: config.lineHeight,
|
lineHeight: config.lineHeight,
|
||||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||||
theme: {
|
theme: {
|
||||||
background: themeColors.background,
|
background: config.backgroundImage
|
||||||
|
? "transparent"
|
||||||
|
: themeColors.background,
|
||||||
foreground: themeColors.foreground,
|
foreground: themeColors.foreground,
|
||||||
cursor: themeColors.cursor,
|
cursor: themeColors.cursor,
|
||||||
cursorAccent: themeColors.cursorAccent,
|
cursorAccent: themeColors.cursorAccent,
|
||||||
@@ -1894,7 +1946,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
uri.startsWith("http://") || uri.startsWith("https://")
|
uri.startsWith("http://") || uri.startsWith("https://")
|
||||||
? uri
|
? uri
|
||||||
: `https://${uri}`;
|
: `https://${uri}`;
|
||||||
|
|
||||||
|
const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior;
|
||||||
|
const globalBehavior =
|
||||||
|
localStorage.getItem("terminalLinkClickBehavior") ?? "confirm";
|
||||||
|
const behavior = hostBehavior ?? globalBehavior;
|
||||||
|
|
||||||
|
if (behavior === "direct") {
|
||||||
window.open(url, "_blank");
|
window.open(url, "_blank");
|
||||||
|
} else {
|
||||||
|
setLinkClickDialog({ url });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
@@ -1906,6 +1968,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal.unicode.activeVersion = "11";
|
terminal.unicode.activeVersion = "11";
|
||||||
|
|
||||||
terminal.open(xtermRef.current);
|
terminal.open(xtermRef.current);
|
||||||
|
terminal.onTitleChange((title) => {
|
||||||
|
if (title) onTitleChange?.(title);
|
||||||
|
});
|
||||||
document.fonts.ready.then(() => {
|
document.fonts.ready.then(() => {
|
||||||
terminal.refresh(0, terminal.rows - 1);
|
terminal.refresh(0, terminal.rows - 1);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
@@ -2017,7 +2082,18 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// On macOS Electron, Tab key events can be swallowed by Chromium's focus
|
||||||
|
// traversal system before xterm.js sees them. Calling preventDefault() in
|
||||||
|
// the capture phase blocks that traversal while still allowing the event to
|
||||||
|
// reach xterm.js's internal handler (which fires our attachCustomKeyEventHandler).
|
||||||
|
const handleTabCapture = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
element?.addEventListener("keydown", handleBackspaceMode, true);
|
element?.addEventListener("keydown", handleBackspaceMode, true);
|
||||||
|
element?.addEventListener("keydown", handleTabCapture, true);
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
@@ -2041,6 +2117,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
||||||
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
||||||
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
||||||
|
element?.removeEventListener("keydown", handleTabCapture, true);
|
||||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||||
};
|
};
|
||||||
@@ -2096,6 +2173,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward global app shortcuts to AppShell directly — xterm swallows
|
||||||
|
// all keydown events and synthetic re-dispatch is unreliable.
|
||||||
|
// stopPropagation prevents the same event from also firing the window listener.
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
const globalCodes = [
|
||||||
|
"BracketRight",
|
||||||
|
"BracketLeft",
|
||||||
|
"Backslash",
|
||||||
|
"Minus",
|
||||||
|
];
|
||||||
|
if (globalCodes.includes(e.code)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
globalShortcutHandler.current?.(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||||
|
const arrowCodes = [
|
||||||
|
"ArrowLeft",
|
||||||
|
"ArrowRight",
|
||||||
|
"ArrowUp",
|
||||||
|
"ArrowDown",
|
||||||
|
];
|
||||||
|
if (arrowCodes.includes(e.code)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
globalShortcutHandler.current?.(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.ctrlKey &&
|
e.ctrlKey &&
|
||||||
!e.shiftKey &&
|
!e.shiftKey &&
|
||||||
@@ -2132,6 +2240,38 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.ctrlKey &&
|
||||||
|
e.shiftKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
e.key.toLowerCase() === "c"
|
||||||
|
) {
|
||||||
|
const selection = terminal.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
writeTextToClipboard(selection);
|
||||||
|
terminal.clearSelection();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.ctrlKey &&
|
||||||
|
e.shiftKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
e.key.toLowerCase() === "v"
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
readTextFromClipboard().then((text) => {
|
||||||
|
if (text) terminal.paste(text);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
e.ctrlKey &&
|
e.ctrlKey &&
|
||||||
!e.shiftKey &&
|
!e.shiftKey &&
|
||||||
@@ -2426,10 +2566,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const hasConnectionError = !!connectionError;
|
const hasConnectionError = !!connectionError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
<div
|
||||||
|
className="h-full w-full relative"
|
||||||
|
style={{
|
||||||
|
backgroundColor: backgroundImage ? "transparent" : backgroundColor,
|
||||||
|
...(backgroundImage && {
|
||||||
|
backgroundImage: `url(${backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backgroundImage && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
opacity: 1 - backgroundImageOpacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={xtermRef}
|
ref={xtermRef}
|
||||||
className="h-full w-full"
|
className="h-full w-full relative"
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: isVisible ? "auto" : "none",
|
pointerEvents: isVisible ? "auto" : "none",
|
||||||
visibility:
|
visibility:
|
||||||
@@ -2656,6 +2816,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setTimeout(() => terminal?.focus(), 50);
|
||||||
}}
|
}}
|
||||||
onCreateNew={() => {
|
onCreateNew={() => {
|
||||||
setTmuxSessionPicker(null);
|
setTmuxSessionPicker(null);
|
||||||
@@ -2667,6 +2828,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
setTimeout(() => terminal?.focus(), 50);
|
||||||
}}
|
}}
|
||||||
onCancel={() => setTmuxSessionPicker(null)}
|
onCancel={() => setTmuxSessionPicker(null)}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
@@ -2680,6 +2842,57 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
position={autocompletePosition}
|
position={autocompletePosition}
|
||||||
onSelect={handleAutocompleteSelect}
|
onSelect={handleAutocompleteSelect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{linkClickDialog && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center z-[150]"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-3 p-4 rounded shadow-lg max-w-sm w-full mx-4"
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("terminal.linkDialogTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm break-all text-foreground select-all">
|
||||||
|
{linkClickDialog.url}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
writeTextToClipboard(linkClickDialog.url);
|
||||||
|
setLinkClickDialog(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("terminal.linkDialogCopy")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(
|
||||||
|
linkClickDialog.url,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
setLinkClickDialog(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("terminal.linkDialogOpen")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLinkClickDialog(null)}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export interface TerminalHandle {
|
|||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
reconnect: () => void;
|
reconnect: () => void;
|
||||||
fit: () => void;
|
fit: () => void;
|
||||||
|
focus: () => void;
|
||||||
sendInput: (data: string) => void;
|
sendInput: (data: string) => void;
|
||||||
notifyResize: () => void;
|
notifyResize: () => void;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
getApplicationCursorKeysMode: () => boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export type StatusColorScheme = "accent" | "status";
|
||||||
|
|
||||||
|
export function useStatusColorScheme(): StatusColorScheme {
|
||||||
|
const [scheme, setScheme] = useState<StatusColorScheme>(
|
||||||
|
() =>
|
||||||
|
(localStorage.getItem("statusColorScheme") as StatusColorScheme) ??
|
||||||
|
"accent",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setScheme(
|
||||||
|
(localStorage.getItem("statusColorScheme") as StatusColorScheme) ??
|
||||||
|
"accent",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener("statusColorSchemeChanged", handler);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("statusColorSchemeChanged", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns Tailwind class names for a status dot/stripe. */
|
||||||
|
export function getStatusClasses(
|
||||||
|
online: boolean,
|
||||||
|
scheme: StatusColorScheme,
|
||||||
|
variant: "dot" | "stripe" | "badge",
|
||||||
|
): string {
|
||||||
|
if (scheme === "status") {
|
||||||
|
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
|
||||||
|
if (variant === "stripe")
|
||||||
|
return online ? "bg-emerald-500" : "bg-red-500/40";
|
||||||
|
// badge
|
||||||
|
return online
|
||||||
|
? "border-emerald-500/40 text-emerald-500 bg-emerald-500/10"
|
||||||
|
: "border-red-500/40 text-red-500 bg-red-500/10";
|
||||||
|
}
|
||||||
|
// accent scheme
|
||||||
|
if (variant === "dot")
|
||||||
|
return online ? "bg-accent-brand" : "bg-muted-foreground/25";
|
||||||
|
if (variant === "stripe")
|
||||||
|
return online ? "bg-accent-brand" : "bg-transparent";
|
||||||
|
// badge
|
||||||
|
return online
|
||||||
|
? "border-accent-brand/40 text-accent-brand bg-accent-brand/10"
|
||||||
|
: "border-border/50 text-muted-foreground/60 bg-muted/30";
|
||||||
|
}
|
||||||
+21
-11
@@ -1,6 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "@fontsource-variable/jetbrains-mono";
|
@import "@fontsource-variable/jetbrains-mono";
|
||||||
|
@import "@fontsource/jetbrains-mono/400.css";
|
||||||
|
@import "@fontsource/jetbrains-mono/400-italic.css";
|
||||||
|
@import "@fontsource/jetbrains-mono/700.css";
|
||||||
|
@import "@fontsource/jetbrains-mono/700-italic.css";
|
||||||
|
@import "@fontsource/fira-code/400.css";
|
||||||
|
@import "@fontsource/fira-code/700.css";
|
||||||
|
@import "@fontsource/source-code-pro/400.css";
|
||||||
|
@import "@fontsource/source-code-pro/400-italic.css";
|
||||||
|
@import "@fontsource/source-code-pro/700.css";
|
||||||
|
@import "@fontsource/source-code-pro/700-italic.css";
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Caskaydia Cove Nerd Font Mono";
|
font-family: "Caskaydia Cove Nerd Font Mono";
|
||||||
@@ -242,38 +252,38 @@
|
|||||||
.nord {
|
.nord {
|
||||||
--background: #2e3440;
|
--background: #2e3440;
|
||||||
--foreground: #eceff4;
|
--foreground: #eceff4;
|
||||||
--card: #272c36;
|
--card: #303642;
|
||||||
--card-foreground: #eceff4;
|
--card-foreground: #eceff4;
|
||||||
--popover: #272c36;
|
--popover: #303642;
|
||||||
--popover-foreground: #eceff4;
|
--popover-foreground: #eceff4;
|
||||||
--primary: #eceff4;
|
--primary: #eceff4;
|
||||||
--primary-foreground: #2e3440;
|
--primary-foreground: #2e3440;
|
||||||
--secondary: #3b4252;
|
--secondary: #3b4252;
|
||||||
--secondary-foreground: #eceff4;
|
--secondary-foreground: #eceff4;
|
||||||
--surface: #272c36;
|
--surface: #303642;
|
||||||
--surface-dim: #22262e;
|
--surface-dim: #262b35;
|
||||||
--muted: #3b4252;
|
--muted: #3b4252;
|
||||||
--muted-foreground: #4c566a;
|
--muted-foreground: #8899b0;
|
||||||
--accent: #3b4252;
|
--accent: #3b4252;
|
||||||
--accent-foreground: #eceff4;
|
--accent-foreground: #eceff4;
|
||||||
--destructive: #bf616a;
|
--destructive: #bf616a;
|
||||||
--border: rgba(76, 86, 106, 0.35);
|
--border: rgba(136, 192, 208, 0.2);
|
||||||
--input: rgba(76, 86, 106, 0.3);
|
--input: rgba(136, 192, 208, 0.15);
|
||||||
--ring: #4c566a;
|
--ring: #88c0d0;
|
||||||
--chart-1: #bf616a;
|
--chart-1: #bf616a;
|
||||||
--chart-2: #88c0d0;
|
--chart-2: #88c0d0;
|
||||||
--chart-3: #a3be8c;
|
--chart-3: #a3be8c;
|
||||||
--chart-4: #ebcb8b;
|
--chart-4: #ebcb8b;
|
||||||
--chart-5: #b48ead;
|
--chart-5: #b48ead;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: #272c36;
|
--sidebar: #282d39;
|
||||||
--sidebar-foreground: #eceff4;
|
--sidebar-foreground: #eceff4;
|
||||||
--sidebar-primary: #88c0d0;
|
--sidebar-primary: #88c0d0;
|
||||||
--sidebar-primary-foreground: #2e3440;
|
--sidebar-primary-foreground: #2e3440;
|
||||||
--sidebar-accent: #3b4252;
|
--sidebar-accent: #3b4252;
|
||||||
--sidebar-accent-foreground: #eceff4;
|
--sidebar-accent-foreground: #eceff4;
|
||||||
--sidebar-border: rgba(76, 86, 106, 0.35);
|
--sidebar-border: rgba(136, 192, 208, 0.2);
|
||||||
--sidebar-ring: #4c566a;
|
--sidebar-ring: #88c0d0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Solarized Dark ─────────────────────────────────────────── */
|
/* ── Solarized Dark ─────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ export class RobustClipboardProvider implements IClipboardProvider {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
navigator.clipboard.writeText(text).catch(() => {
|
navigator.clipboard.writeText(text).catch(() => {
|
||||||
this.pendingWrite = text;
|
this.pendingWrite = text;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
this.pendingWrite = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("focus", this.focusHandler);
|
window.addEventListener("focus", this.focusHandler);
|
||||||
@@ -47,7 +51,11 @@ export class RobustClipboardProvider implements IClipboardProvider {
|
|||||||
await window.electronClipboard.writeText(text);
|
await window.electronClipboard.writeText(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
this.pendingWrite = text;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.pendingWrite = text;
|
this.pendingWrite = text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Module-level ref so xterm's key handler can invoke app-level shortcuts
|
||||||
|
// without going through synthetic DOM events.
|
||||||
|
export const globalShortcutHandler = {
|
||||||
|
current: null as ((e: KeyboardEvent) => void) | null,
|
||||||
|
};
|
||||||
@@ -154,7 +154,7 @@ describe("highlightTerminalOutput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("processes multi-line text line by line", () => {
|
it("processes multi-line text line by line", () => {
|
||||||
const text = "some output\nERROR: failed";
|
const text = "some output\nERROR: failed\n";
|
||||||
const out = highlightTerminalOutput(text);
|
const out = highlightTerminalOutput(text);
|
||||||
expect(out).toContain(ESC + "[91m");
|
expect(out).toContain(ESC + "[91m");
|
||||||
});
|
});
|
||||||
@@ -198,4 +198,91 @@ describe("highlightTerminalOutput", () => {
|
|||||||
});
|
});
|
||||||
expect(out).not.toContain(ESC + "[96m");
|
expect(out).not.toContain(ESC + "[96m");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips highlighting when chunk contains a mid-line carriage return", () => {
|
||||||
|
// Progress bars and shell prompt redraws use \r to overwrite the current line
|
||||||
|
const chunk = "downloading...\rdownloading [====] 100%";
|
||||||
|
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still processes CRLF line endings (\\r\\n is fine)", () => {
|
||||||
|
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||||
|
expect(out).toContain(ESC + "[91m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight shell prompt lines (user@host:path$)", () => {
|
||||||
|
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
|
||||||
|
const out = highlightTerminalOutput(prompt);
|
||||||
|
expect(out).toBe(prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight plain-text shell prompt line", () => {
|
||||||
|
const prompt = "pi@raspberrypi:/home/pi$ ";
|
||||||
|
const out = highlightTerminalOutput(prompt);
|
||||||
|
expect(out).toBe(prompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
|
||||||
|
// Some shells print "success~/new/dir" or "success/path" after a cd command
|
||||||
|
const out = highlightTerminalOutput("success~/home/user/projects");
|
||||||
|
expect(out).not.toContain(ESC + "[92m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still highlights 'success' when not followed by a path separator", () => {
|
||||||
|
const out = highlightTerminalOutput("Build success: all tests passed");
|
||||||
|
expect(out).toContain(ESC + "[92m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights output lines but not the prompt in a mixed chunk", () => {
|
||||||
|
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
|
||||||
|
const out = highlightTerminalOutput(chunk);
|
||||||
|
// The error line should be highlighted
|
||||||
|
expect(out).toContain(ESC + "[91m");
|
||||||
|
// The prompt line should be unchanged
|
||||||
|
expect(out).toContain("pi@raspberrypi:~$ ");
|
||||||
|
const promptLine = out.split("\n")[1];
|
||||||
|
expect(promptLine).toBe("pi@raspberrypi:~$ ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
|
||||||
|
// When the shell echoes the user's command, it prefixes the prompt.
|
||||||
|
// The path in the prompt portion (/home/user) must not be highlighted —
|
||||||
|
// the shell already colored it, and re-coloring it causes the doubled-path bug.
|
||||||
|
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
|
||||||
|
const out = highlightTerminalOutput(echo);
|
||||||
|
expect(out).toBe(echo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
|
||||||
|
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
|
||||||
|
const out = highlightTerminalOutput(echo);
|
||||||
|
expect(out).toBe(echo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
|
||||||
|
// In a multi-line chunk, the trailing fragment without \n could be a live
|
||||||
|
// readline input line. Injecting ANSI bytes there breaks bash's cursor
|
||||||
|
// arithmetic (causes cursor jump / text shift when using arrow keys).
|
||||||
|
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
|
||||||
|
const out = highlightTerminalOutput(chunk);
|
||||||
|
// First line (terminated by \n) gets highlighted
|
||||||
|
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||||
|
// Last unterminated fragment is left verbatim
|
||||||
|
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still highlights a single-line chunk with no trailing newline", () => {
|
||||||
|
// A single-line chunk with no \n is plain command output, not a readline
|
||||||
|
// input fragment — highlight it normally.
|
||||||
|
const out = highlightTerminalOutput("ERROR: something failed");
|
||||||
|
expect(out).toContain(ESC + "[91m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights all lines when the chunk ends with a newline", () => {
|
||||||
|
// When a chunk ends with \n every line is complete output — highlight them all.
|
||||||
|
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
|
||||||
|
const out = highlightTerminalOutput(chunk);
|
||||||
|
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||||
|
expect(out.split("\n")[1]).toContain(ESC + "[36m");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ const MAX_LINE_LENGTH = 2000;
|
|||||||
// If a chunk contains these, we skip highlighting entirely.
|
// If a chunk contains these, we skip highlighting entirely.
|
||||||
const TUI_SEQUENCE = /\x1b\[[\d;]*[ABCDEFGHJKST]/;
|
const TUI_SEQUENCE = /\x1b\[[\d;]*[ABCDEFGHJKST]/;
|
||||||
|
|
||||||
|
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||||
|
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||||
|
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||||
|
const MID_LINE_CR = /\r(?!\n)/;
|
||||||
|
|
||||||
|
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||||
|
// These should not be highlighted — the server already colored them, and injecting
|
||||||
|
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||||
|
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||||
|
|
||||||
|
function isShellPromptLine(bare: string): boolean {
|
||||||
|
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||||
|
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||||
|
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||||
|
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||||
|
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||||
|
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||||
|
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Matches any complete ANSI escape sequence
|
// Matches any complete ANSI escape sequence
|
||||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||||
|
|
||||||
@@ -114,10 +136,13 @@ const ALL_PATTERNS: HighlightPattern[] = [
|
|||||||
category: "logLevels",
|
category: "logLevels",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Success keywords — kept conservative to avoid noise
|
// Success keywords — kept conservative to avoid noise.
|
||||||
|
// Negative lookahead prevents matching when directly followed by a path separator
|
||||||
|
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||||
{
|
{
|
||||||
name: "log-success",
|
name: "log-success",
|
||||||
regex: /\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b/gi,
|
regex:
|
||||||
|
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||||
ansiCode: ANSI.brightGreen,
|
ansiCode: ANSI.brightGreen,
|
||||||
priority: 8,
|
priority: 8,
|
||||||
category: "logLevels",
|
category: "logLevels",
|
||||||
@@ -313,6 +338,7 @@ function highlightLine(
|
|||||||
const bare = cr ? line.slice(0, -1) : line;
|
const bare = cr ? line.slice(0, -1) : line;
|
||||||
|
|
||||||
if (!bare.trim()) return line;
|
if (!bare.trim()) return line;
|
||||||
|
if (isShellPromptLine(bare)) return line;
|
||||||
|
|
||||||
const segments = parseAnsiSegments(bare);
|
const segments = parseAnsiSegments(bare);
|
||||||
const result = segments
|
const result = segments
|
||||||
@@ -334,12 +360,29 @@ export function highlightTerminalOutput(
|
|||||||
if (hasIncompleteAnsiSequence(text)) return text;
|
if (hasIncompleteAnsiSequence(text)) return text;
|
||||||
|
|
||||||
if (TUI_SEQUENCE.test(text)) return text;
|
if (TUI_SEQUENCE.test(text)) return text;
|
||||||
|
if (MID_LINE_CR.test(text)) return text;
|
||||||
|
|
||||||
const activePatterns = buildActivePatterns(options);
|
const activePatterns = buildActivePatterns(options);
|
||||||
if (activePatterns.length === 0) return text;
|
if (activePatterns.length === 0) return text;
|
||||||
|
|
||||||
return text
|
const lines = text.split("\n");
|
||||||
.split("\n")
|
const endsWithNewline = text.endsWith("\n");
|
||||||
.map((line) => highlightLine(line, activePatterns))
|
const hasMultipleLines = lines.length > 1;
|
||||||
|
|
||||||
|
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||||
|
// could be a live readline input line that bash will redraw with \r +
|
||||||
|
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||||
|
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||||
|
// xterm's actual column position — causing the cursor to jump and text to
|
||||||
|
// shift when the user types or navigates with arrow keys.
|
||||||
|
// Only skip the last fragment when the chunk already has complete lines
|
||||||
|
// before it (single-line chunks with no \n are plain output, not input).
|
||||||
|
const highlightCount =
|
||||||
|
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||||
|
|
||||||
|
return lines
|
||||||
|
.map((line, i) =>
|
||||||
|
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||||
|
)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -821,6 +821,8 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
|||||||
keepaliveInterval: undefined as number | undefined,
|
keepaliveInterval: undefined as number | undefined,
|
||||||
keepaliveCountMax: undefined as number | undefined,
|
keepaliveCountMax: undefined as number | undefined,
|
||||||
autoTmux: false,
|
autoTmux: false,
|
||||||
|
backgroundImage: "" as string,
|
||||||
|
backgroundImageOpacity: 0.15,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user