mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1a8dcf3c6 | |||
| 8072b4ede9 | |||
| 688e662042 | |||
| 612ba046a8 | |||
| 27d613e50c | |||
| b02e9876ae | |||
| e2db514173 | |||
| ac24fdcba8 | |||
| 580c284065 | |||
| 2d5da0439e | |||
| 4d04559ca5 | |||
| f0f3e0b063 |
@@ -5,4 +5,4 @@ contact_links:
|
||||
about: Report any feature requests or bugs in the support center
|
||||
- name: Discord
|
||||
url: https://discord.gg/jVQGdvHDrf
|
||||
about: Official Termix Discord server for general discussion and quick support
|
||||
about: Official Termix Discord server for general discussion (not recommended for support)
|
||||
|
||||
+47
-4
@@ -1,9 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# npm dependencies (single root package.json, no workspaces)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 15
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
prefix-development: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
@@ -21,20 +31,53 @@ updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
# Major bumps grouped so they land as a single reviewable PR instead of
|
||||
# one-per-package noise. These often need manual follow-up (Electron,
|
||||
# React, Vite, Tailwind, Express 5, etc.).
|
||||
major-updates:
|
||||
update-types:
|
||||
- "major"
|
||||
|
||||
# Docker base images (docker/Dockerfile + docker-compose / compose-dev)
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
groups:
|
||||
patch-updates:
|
||||
docker-patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
minor-updates:
|
||||
docker-minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
docker-major-updates:
|
||||
update-types:
|
||||
- "major"
|
||||
|
||||
# GitHub Actions used across the workflows in .github/workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
groups:
|
||||
github-actions:
|
||||
update-types:
|
||||
- "patch"
|
||||
- "minor"
|
||||
- "major"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
name: Retarget and Merge Dependabot PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
retarget-and-merge:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve newest dev branch
|
||||
id: dev
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
|
||||
# The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
|
||||
# as "nothing to do" rather than a workflow failure.
|
||||
if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
|
||||
echo "Newest dev branch: $DEV_BRANCH"
|
||||
echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No dev-X.Y.Z branch open; nothing to retarget."
|
||||
echo "branch=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Retarget and merge Dependabot PRs
|
||||
if: ${{ steps.dev.outputs.branch != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
DEV_BRANCH: ${{ steps.dev.outputs.branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
CONFLICT_LABEL="dependabot-rebase-requested"
|
||||
|
||||
# Ensure the bookkeeping label exists (no-op if it already does).
|
||||
gh label create "$CONFLICT_LABEL" --repo "$REPO" \
|
||||
--color "D93F0B" --description "Retarget workflow asked Dependabot to rebase a conflicting PR" \
|
||||
2>/dev/null || true
|
||||
|
||||
# True if the PR already carries the conflict label.
|
||||
has_conflict_label() {
|
||||
gh pr view "$1" --repo "$REPO" --json labels \
|
||||
-q '.labels[].name' | grep -qx "$CONFLICT_LABEL"
|
||||
}
|
||||
|
||||
# Wait until GitHub has a definite mergeable verdict for a PR (it
|
||||
# returns UNKNOWN while recomputing after a base change or a push).
|
||||
# Echoes "<mergeable> <mergeStateStatus>".
|
||||
wait_for_verdict() {
|
||||
local pr="$1" mergeable state
|
||||
for _ in $(seq 1 30); do
|
||||
read -r mergeable state < <(gh pr view "$pr" --repo "$REPO" \
|
||||
--json mergeable,mergeStateStatus \
|
||||
-q '.mergeable + " " + .mergeStateStatus')
|
||||
if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then
|
||||
echo "$mergeable $state"
|
||||
return 0
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
echo "$mergeable $state"
|
||||
}
|
||||
|
||||
# Phase 1: retarget every open Dependabot PR from main onto the dev
|
||||
# branch. This kicks off a Dependabot rebase for each.
|
||||
PR_NUMBERS=$(gh pr list --repo "$REPO" \
|
||||
--author "app/dependabot" \
|
||||
--base main \
|
||||
--state open \
|
||||
--json number -q '.[].number')
|
||||
|
||||
# Pick up PRs already sitting on the dev branch from a previous run too.
|
||||
PR_NUMBERS="$PR_NUMBERS $(gh pr list --repo "$REPO" \
|
||||
--author "app/dependabot" \
|
||||
--base "$DEV_BRANCH" \
|
||||
--state open \
|
||||
--json number -q '.[].number')"
|
||||
|
||||
PR_NUMBERS=$(printf '%s\n' $PR_NUMBERS | sort -un)
|
||||
|
||||
if [ -z "$PR_NUMBERS" ]; then
|
||||
echo "No open Dependabot PRs to process."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for PR in $PR_NUMBERS; do
|
||||
BASE=$(gh pr view "$PR" --repo "$REPO" --json baseRefName -q .baseRefName)
|
||||
if [ "$BASE" != "$DEV_BRANCH" ]; then
|
||||
echo "Retargeting PR #$PR ($BASE -> $DEV_BRANCH)"
|
||||
gh pr edit "$PR" --repo "$REPO" --base "$DEV_BRANCH"
|
||||
fi
|
||||
done
|
||||
|
||||
# Phase 2: merge one at a time. Each merge can make the remaining npm
|
||||
# PRs stale, so re-check immediately before merging and rebase stragglers.
|
||||
for PR in $PR_NUMBERS; do
|
||||
echo "::group::PR #$PR"
|
||||
|
||||
read -r MERGEABLE STATE < <(wait_for_verdict "$PR")
|
||||
echo " mergeable=$MERGEABLE mergeStateStatus=$STATE"
|
||||
|
||||
# BEHIND = clean but needs the latest base; ask Dependabot to rebase
|
||||
# and skip for now (next run merges it once it is up to date).
|
||||
if [ "$STATE" = "BEHIND" ]; then
|
||||
echo " PR #$PR is behind $DEV_BRANCH; asking Dependabot to rebase."
|
||||
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# DIRTY / CONFLICTING = a real conflict. Try a rebase once (label it so
|
||||
# we can tell next time); if it is STILL conflicting on a later run
|
||||
# despite already being labelled, the rebase failed for good - close it
|
||||
# so Dependabot reopens a fresh PR against the current dev branch.
|
||||
if [ "$MERGEABLE" = "CONFLICTING" ] || [ "$STATE" = "DIRTY" ]; then
|
||||
if has_conflict_label "$PR"; then
|
||||
echo " PR #$PR still conflicts after a prior rebase request; closing so Dependabot reopens it fresh."
|
||||
gh pr close "$PR" --repo "$REPO" --delete-branch \
|
||||
--comment "Closing: this PR still conflicts with $DEV_BRANCH after a rebase attempt (its changes are likely already merged). Dependabot will reopen a fresh PR computed against the current $DEV_BRANCH."
|
||||
else
|
||||
echo " PR #$PR conflicts with $DEV_BRANCH; requesting a rebase and labelling it."
|
||||
gh pr edit "$PR" --repo "$REPO" --add-label "$CONFLICT_LABEL"
|
||||
gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
|
||||
# A clean PR that was previously flagged has recovered - drop the label.
|
||||
if has_conflict_label "$PR"; then
|
||||
gh pr edit "$PR" --repo "$REPO" --remove-label "$CONFLICT_LABEL" || true
|
||||
fi
|
||||
|
||||
echo " Squash-merging PR #$PR"
|
||||
if gh pr merge "$PR" --repo "$REPO" --squash --admin; then
|
||||
echo " Merged PR #$PR"
|
||||
# Give GitHub a moment to mark the now-stale siblings BEHIND.
|
||||
sleep 15
|
||||
else
|
||||
echo " Could not merge PR #$PR now; it will be retried next run."
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
done
|
||||
@@ -24,13 +24,18 @@ on:
|
||||
description: "Build type (Development or Production)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Build the image but do not push to any registry"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -52,7 +57,7 @@ jobs:
|
||||
ALL_TAGS=()
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("release-$VERSION" "latest")
|
||||
TAGS+=("release-$VERSION" "$VERSION" "latest")
|
||||
for tag in "${TAGS[@]}"; do
|
||||
ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
|
||||
ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
|
||||
@@ -67,6 +72,7 @@ jobs:
|
||||
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to GHCR
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -74,7 +80,7 @@ jobs:
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub (prod only)
|
||||
if: ${{ inputs.build_type == 'Production' }}
|
||||
if: ${{ inputs.build_type == 'Production' && !inputs.dry_run }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: bugattiguy527
|
||||
@@ -85,7 +91,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
push: ${{ !inputs.dry_run }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
@@ -96,7 +102,7 @@ jobs:
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=registry,compression=zstd
|
||||
outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
|
||||
+125
-57
@@ -52,12 +52,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
run: npm run build && npx electron-builder --win --x64 --ia32
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_nsis
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_nsis
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows x64 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_msi
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_msi
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
|
||||
|
||||
- name: Upload Windows x64 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_portable
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows ia32 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('termix_windows_ia32_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_portable
|
||||
@@ -142,12 +142,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
cd ..
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_appimage
|
||||
@@ -198,7 +198,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_appimage
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_appimage
|
||||
@@ -214,7 +214,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux x64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_deb
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_deb
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_deb
|
||||
@@ -238,7 +238,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux x64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_portable
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux arm64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_portable
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux armv7l tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_portable
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpak
|
||||
@@ -334,7 +334,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Flatpakref
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpakref
|
||||
@@ -352,12 +352,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: termix_macos_universal_mas
|
||||
path: release/termix_macos_universal_mas.pkg
|
||||
@@ -488,7 +488,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload macOS Universal DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_universal_dmg
|
||||
@@ -496,7 +496,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload macOS x64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_x64_dmg
|
||||
@@ -504,7 +504,7 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload macOS arm64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_arm64_dmg
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb
|
||||
|
||||
- name: Upload Homebrew Cask as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'file'
|
||||
with:
|
||||
name: termix_macos_homebrew_cask
|
||||
@@ -578,7 +578,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload Chocolatey package as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: chocolatey-package
|
||||
path: choco-build/*.nupkg
|
||||
@@ -684,7 +684,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -734,8 +734,6 @@ jobs:
|
||||
CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
|
||||
CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
|
||||
RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
|
||||
APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
|
||||
APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"
|
||||
|
||||
mkdir -p flatpak-submission
|
||||
|
||||
@@ -756,12 +754,64 @@ jobs:
|
||||
sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml
|
||||
|
||||
- name: Upload Flatpak submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: flatpak-submission
|
||||
path: flatpak-submission/*
|
||||
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:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||
@@ -771,7 +821,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -825,7 +875,7 @@ jobs:
|
||||
ruby -c homebrew-submission/Casks/t/termix.rb
|
||||
|
||||
- name: Upload Homebrew submission as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: homebrew-submission
|
||||
path: homebrew-submission/*
|
||||
@@ -840,7 +890,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
@@ -872,7 +922,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
submit-to-testflight:
|
||||
submit-to-app-store:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||
needs: []
|
||||
@@ -881,12 +931,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -943,9 +993,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
BUILD_VERSION="${{ github.run_number }}"
|
||||
|
||||
npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"
|
||||
|
||||
- name: Check for App Store Connect API credentials
|
||||
@@ -956,36 +1004,56 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup Ruby for Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: "3.2"
|
||||
ruby-version: "3.3"
|
||||
bundler-cache: false
|
||||
|
||||
- name: Install Fastlane
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: |
|
||||
gem install fastlane -N
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
run: gem install fastlane -N
|
||||
|
||||
- name: Deploy to App Store Connect (TestFlight)
|
||||
if: steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
- name: Upload and submit to Mac App Store
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
|
||||
env:
|
||||
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
||||
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
|
||||
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||
run: |
|
||||
PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
|
||||
if [ -z "$PKG_FILE" ]; then
|
||||
echo "PKG file not found, exiting."
|
||||
echo "PKG file not found in release/; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/private_keys
|
||||
echo "${{ secrets.APPLE_KEY_CONTENT }}" | base64 --decode > ~/private_keys/AuthKey_${{ secrets.APPLE_KEY_ID }}.p8
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
xcrun altool --upload-app -f "$PKG_FILE" \
|
||||
--type macos \
|
||||
--apiKey "${{ secrets.APPLE_KEY_ID }}" \
|
||||
--apiIssuer "${{ secrets.APPLE_ISSUER_ID }}"
|
||||
continue-on-error: true
|
||||
# Write API key JSON that Fastlane deliver expects
|
||||
mkdir -p /tmp/asc_keys
|
||||
KEY_P8_PATH="/tmp/asc_keys/AuthKey_${APPLE_KEY_ID}.p8"
|
||||
API_KEY_JSON="/tmp/asc_keys/api_key.json"
|
||||
|
||||
echo "$APPLE_KEY_CONTENT" | base64 --decode > "$KEY_P8_PATH"
|
||||
|
||||
printf '{\n "key_id": "%s",\n "issuer_id": "%s",\n "key": "%s",\n "in_house": false\n}\n' \
|
||||
"$APPLE_KEY_ID" \
|
||||
"$APPLE_ISSUER_ID" \
|
||||
"$(tr -d '\n' < "$KEY_P8_PATH")" \
|
||||
> "$API_KEY_JSON"
|
||||
|
||||
fastlane deliver \
|
||||
--pkg "$PKG_FILE" \
|
||||
--api_key_path "$API_KEY_JSON" \
|
||||
--app_version "$VERSION" \
|
||||
--skip_metadata true \
|
||||
--skip_screenshots true \
|
||||
--submit_for_review true \
|
||||
--automatic_release true \
|
||||
--force true
|
||||
|
||||
- name: Clean up keychains
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -rf /tmp/asc_keys
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
run: npm run generate:openapi
|
||||
|
||||
- name: Upload OpenAPI artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openapi-spec
|
||||
path: openapi.json
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
+413
-22
@@ -2,6 +2,17 @@ name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: "Release mode"
|
||||
required: true
|
||||
default: Everything
|
||||
type: choice
|
||||
options:
|
||||
- Everything
|
||||
- Overwrite release
|
||||
- Dry run
|
||||
- Skip submit
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -13,20 +24,27 @@ jobs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
release_tag: ${{ steps.info.outputs.release_tag }}
|
||||
mobile_version: ${{ steps.info.outputs.mobile_version }}
|
||||
dev_branch: ${{ steps.info.outputs.dev_branch }}
|
||||
steps:
|
||||
- name: Guard branch
|
||||
if: github.ref != 'refs/heads/main'
|
||||
- name: Guard branch (release modes)
|
||||
if: ${{ inputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
|
||||
run: |
|
||||
echo "Releases must be run from main (got ${{ github.ref }})."
|
||||
echo "This mode must be run from a dev branch (got ${{ github.ref }})."
|
||||
exit 1
|
||||
|
||||
- name: Guard branch (overwrite mode)
|
||||
if: ${{ inputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
|
||||
run: |
|
||||
echo "Overwrite release must be run from main (got ${{ github.ref }})."
|
||||
exit 1
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
@@ -35,7 +53,14 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
|
||||
DEV_BRANCH=""
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
else
|
||||
DEV_BRANCH="${GITHUB_REF#refs/heads/}"
|
||||
VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
|
||||
fi
|
||||
echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -54,29 +79,249 @@ jobs:
|
||||
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
|
||||
--notes RELEASE_NOTES.md > /dev/null
|
||||
|
||||
docker:
|
||||
normalize:
|
||||
needs: [prep]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: Production
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
needs: [prep, docker]
|
||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint and format
|
||||
run: |
|
||||
npm run lint:fix || true
|
||||
npm run format
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test
|
||||
|
||||
- name: Sync version
|
||||
run: node scripts/sync-version.cjs --version "${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Commit changes to dev branch
|
||||
run: |
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "No lint/format/version changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:"
|
||||
git --no-pager diff --stat
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
|
||||
crowdin:
|
||||
needs: [prep, normalize]
|
||||
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.prep.outputs.dev_branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
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
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
|
||||
fi
|
||||
git push origin --delete i18n_translate || true
|
||||
|
||||
- name: Upload sources to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||
project_id: "858252"
|
||||
env:
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||
|
||||
- name: Machine pre-translate untranslated strings
|
||||
env:
|
||||
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
|
||||
run: node scripts/crowdin-pretranslate.cjs
|
||||
|
||||
- name: Download translations from Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
token: ${{ secrets.CROWDIN_API_KEY }}
|
||||
project_id: "858252"
|
||||
env:
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
|
||||
|
||||
- name: Commit translations to dev branch
|
||||
run: |
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
|
||||
git add src/ui/locales/translated
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
|
||||
merge-to-main:
|
||||
needs: [prep, normalize, crowdin]
|
||||
if: ${{ always() && needs.prep.result == 'success' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
main_sha: ${{ steps.merge.outputs.main_sha }}
|
||||
build_ref: ${{ steps.merge.outputs.build_ref }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Generate PR body
|
||||
id: body
|
||||
run: |
|
||||
node scripts/generate-release-body.cjs \
|
||||
--version "${{ needs.prep.outputs.version }}" \
|
||||
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
||||
--notes RELEASE_NOTES.md > PR_BODY.md
|
||||
|
||||
- name: Open and squash-merge PR to main
|
||||
id: merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
|
||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
||||
|
||||
if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
|
||||
echo "OVERWRITE: no merge; building from main as-is."
|
||||
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
||||
echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main."
|
||||
echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main."
|
||||
echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
|
||||
echo "main_sha=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$DEV_BRANCH" --base main --state open --json number -q '.[0].number' || true)
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
PR_NUMBER=$(gh pr create --repo ${{ github.repository }} \
|
||||
--base main --head "$DEV_BRANCH" \
|
||||
--title "$TITLE" --body-file PR_BODY.md \
|
||||
| grep -oE '[0-9]+$')
|
||||
fi
|
||||
|
||||
gh pr merge "$PR_NUMBER" --repo ${{ github.repository }} --squash --admin
|
||||
|
||||
STATE=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json state -q .state)
|
||||
if [ "$STATE" != "MERGED" ]; then
|
||||
echo "PR #$PR_NUMBER did not merge (state: $STATE)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
||||
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
needs: [prep, merge-to-main]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: Production
|
||||
dry_run: ${{ inputs.mode == 'Dry run' }}
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
needs: [prep, merge-to-main, docker]
|
||||
if: ${{ inputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Clear existing release artifacts (overwrite mode)
|
||||
if: ${{ inputs.mode == 'Overwrite release' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||
if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
||||
echo "Overwrite release: no existing release $TAG to overwrite."
|
||||
exit 1
|
||||
fi
|
||||
echo "Clearing existing assets from $TAG..."
|
||||
gh release view "$TAG" --repo ${{ github.repository }} --json assets -q '.assets[].name' | while read -r ASSET; do
|
||||
[ -n "$ASSET" ] && gh release delete-asset "$TAG" "$ASSET" --repo ${{ github.repository }} --yes || true
|
||||
done
|
||||
|
||||
- name: Generate release body
|
||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||
run: |
|
||||
node scripts/generate-release-body.cjs \
|
||||
--version "${{ needs.prep.outputs.version }}" \
|
||||
@@ -84,6 +329,7 @@ jobs:
|
||||
--notes RELEASE_NOTES.md > RELEASE_BODY.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -92,20 +338,22 @@ jobs:
|
||||
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
|
||||
else
|
||||
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ github.sha }}"
|
||||
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}"
|
||||
fi
|
||||
|
||||
electron-release:
|
||||
needs: [prep, create-release]
|
||||
needs: [prep, merge-to-main, create-release]
|
||||
if: ${{ always() && needs.merge-to-main.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
artifact_destination: release
|
||||
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
|
||||
electron-submit:
|
||||
needs: [electron-release]
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
@@ -114,12 +362,13 @@ jobs:
|
||||
|
||||
cask-commit-back:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ inputs.mode != 'Dry run' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
@@ -146,5 +395,147 @@ jobs:
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git add Casks/termix.rb
|
||||
git stash
|
||||
git pull --rebase origin main
|
||||
git stash pop
|
||||
git commit -m "chore: bump Homebrew cask to $VERSION"
|
||||
git push origin HEAD:main
|
||||
|
||||
docs:
|
||||
needs: [prep, merge-to-main]
|
||||
if: ${{ always() && needs.merge-to-main.result == 'success' && inputs.mode != 'Overwrite release' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout Termix
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||
fetch-depth: 1
|
||||
path: termix
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: "termix/.nvmrc"
|
||||
cache: "npm"
|
||||
cache-dependency-path: termix/package-lock.json
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
working-directory: termix
|
||||
run: |
|
||||
npm ci
|
||||
npm run generate:openapi
|
||||
|
||||
- name: Checkout Docs repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: Termix-SSH/Docs
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
path: docs-repo
|
||||
|
||||
- name: Create docs release branch
|
||||
working-directory: docs-repo
|
||||
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Overwrite OpenAPI spec and regenerate API docs
|
||||
working-directory: docs-repo
|
||||
run: |
|
||||
cp ../termix/openapi.json static/openapi.json
|
||||
npm ci
|
||||
npm run docusaurus gen-api-docs termix
|
||||
|
||||
- name: Commit and push docs branch
|
||||
working-directory: docs-repo
|
||||
run: |
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "No docs changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit and push the following docs changes to dev-${{ needs.prep.outputs.version }}:"
|
||||
git --no-pager diff --stat
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
|
||||
git push --force origin "dev-${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Open and squash-merge docs PR
|
||||
if: ${{ inputs.mode != 'Dry run' }}
|
||||
working-directory: docs-repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
BRANCH="dev-${{ needs.prep.outputs.version }}"
|
||||
if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
echo "No docs branch pushed (nothing changed); skipping PR."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PR_NUMBER=$(gh pr list --repo Termix-SSH/Docs --head "$BRANCH" --base main --state open --json number -q '.[0].number' || true)
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
PR_NUMBER=$(gh pr create --repo Termix-SSH/Docs \
|
||||
--base main --head "$BRANCH" \
|
||||
--title "release-${{ needs.prep.outputs.version }}" \
|
||||
--body "API docs for ${{ needs.prep.outputs.version }}" \
|
||||
| grep -oE '[0-9]+$')
|
||||
fi
|
||||
|
||||
gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin
|
||||
|
||||
publish-youtube:
|
||||
needs: [prep, electron-release]
|
||||
if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Set release video to public
|
||||
env:
|
||||
YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
|
||||
YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
|
||||
YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
|
||||
run: node scripts/publish-youtube.cjs
|
||||
|
||||
cleanup:
|
||||
needs:
|
||||
[
|
||||
prep,
|
||||
merge-to-main,
|
||||
electron-release,
|
||||
cask-commit-back,
|
||||
docs,
|
||||
publish-youtube,
|
||||
]
|
||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' && needs.cask-commit-back.result == 'success' && needs.docs.result == 'success' && needs.publish-youtube.result == 'success' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Delete dev branch in Termix
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/${{ github.repository }}.git \
|
||||
--delete "${{ needs.prep.outputs.dev_branch }}" || true
|
||||
|
||||
- name: Delete dev branch in Docs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/Termix-SSH/Docs.git \
|
||||
--delete "dev-${{ needs.prep.outputs.version }}" || true
|
||||
|
||||
@@ -33,3 +33,5 @@ electron/build-info.cjs
|
||||
/.mcp.json
|
||||
/CLAUDE.md
|
||||
/old_db/
|
||||
/scripts/fix-bugs.mjs
|
||||
/scripts/fix-features.mjs
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "2.3.1"
|
||||
sha256 "5b61bd0f9de0b0a2a0af737ac208df157626e8eeed3b54f515645124dea8192a"
|
||||
version "2.4.0"
|
||||
sha256 "3cc1afc2c62ce9f40124561fb40374b34e41dcf8610b62f773a4661e9c83ca85"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -94,59 +94,67 @@ Start, stop, pause, remove containers. View container stats. Control container u
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH Host Manager:**
|
||||
Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys.
|
||||
Save, organize, and manage your SSH connections with tags and folders (folder customization and nested folder support), and easily save reusable login info while being able to automate the deployment of SSH keys.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Server Stats:**
|
||||
View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers.
|
||||
**Host Metrics:**
|
||||
View CPU, memory, disk usage, network, uptime, system information, firewall, port monitor, log viewer, users/permissions, certificates, and many more which work on most Linux based servers.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**User Authentication:**
|
||||
Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
|
||||
Secure user management with admin controls and OIDC/LDAP/SSO (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale Integration:**
|
||||
List devices from your tailnet to quickly add them as hosts, and connect using Tailscale SSH as an authentication method, letting your tailnet ACLs handle authorization without storing credentials.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Create roles and share hosts across users/roles.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Database Encryption:**
|
||||
Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Network Graph:**
|
||||
Customize your Dashboard to visualize your homelab based off your SSH connections with status support.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH Tools:**
|
||||
Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Persistent Tabs:**
|
||||
SSH sessions and tabs stay open across devices/refreshes if enabled in user profile.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Languages:**
|
||||
@@ -170,7 +178,8 @@ Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/tr
|
||||
- **Command History** - Auto-complete and view previously ran SSH commands
|
||||
- **Quick Connect** - Connect to a server without having to save the connection data
|
||||
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
|
||||
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Proxmox Integration** - Auto-add hosts into Termix from your Proxmox instance
|
||||
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal logging, etc.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -306,7 +315,7 @@ networks:
|
||||
|
||||
## Planned Features
|
||||
|
||||
See [Projects](https://github.com/orgs/Termix-SSH/projects/2) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+106
-38
@@ -1,55 +1,123 @@
|
||||
<!-- SUMMARY -->
|
||||
|
||||
Bug fixes and new features including host-to-host file transfer, OIDC improvements, UI/UX updates, and numerous stability and security patches.
|
||||
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 -->
|
||||
|
||||
<!-- YOUTUBE -->
|
||||
|
||||
https://youtu.be/At8iDk6-Q_s
|
||||
https://youtu.be/ImwAbm4hW-k
|
||||
|
||||
<!-- /YOUTUBE -->
|
||||
|
||||
<!-- UPDATE_LOG -->
|
||||
|
||||
- Added in-line buttons on host name row when using click to expand hosts in hosts list
|
||||
- Expose admin_group via OIDC_ADMIN_GROUP env var
|
||||
- Sync appearance preferences
|
||||
- Support native OIDC callbacks
|
||||
- Add portal Desktop DBUS permission for Flatpak URL opening
|
||||
- Restore host password copy
|
||||
- Support for single-host direct tunnels (ssh -L)
|
||||
- Host to host file transfer in file manager
|
||||
- Show ip/username without having to hover over hosts
|
||||
- Updated credential list UI to match UI/UX of host list
|
||||
- Restore rename host folder UI
|
||||
- VNC, RDP, and Telnet credential sharing support
|
||||
- Default host settings (SOCKS5, credentials, terminal settings, and more)
|
||||
- Custom terminal background image per host
|
||||
- Silent OIDC login configurable as default (no URL parameter required)
|
||||
- Bulk open SSH sessions for multiple selected hosts at once
|
||||
- Improved Nord theme contrast and accessibility
|
||||
- Admin can manually create users while registration is disabled
|
||||
- OIDC auto-provisioned users supported when registration and password login are disabled
|
||||
- OIDC username usable as SSH username credential
|
||||
- Custom labels and drag-to-reorder for sessions in the Connections panel
|
||||
- Appearance and profile settings persisted to the database across devices
|
||||
- Saved server URL dropdown in the app connection screen
|
||||
- File manager text editor font size adjustment
|
||||
- Tab key shortcut for entering your username in the terminal
|
||||
- Shift+Tab hotkey support for mobile
|
||||
- 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 -->
|
||||
|
||||
<!-- BUG_FIXES -->
|
||||
|
||||
- Several security vulnerabilities
|
||||
- Allow navigating away from split-view to non-pane tabs
|
||||
- Restore SSH keepalive internal to use 30s to prevent random disconnects
|
||||
- Apply guacamole-lite protocol patch in Docker builds
|
||||
- Show correct icons for network interface types
|
||||
- Resolve sudo password for shared host users
|
||||
- Use jump hosts for online status check and metric collection
|
||||
- Broaden sudo prompt detection for newer distros
|
||||
- Recalculate terminal layout after web fonts load
|
||||
- Improve terminal cwd detection and initial directory command
|
||||
- Decode base64 file content as UTF-8 in file manager
|
||||
- Normalize lazy import default export for iOS compatibility
|
||||
- Prevent RDP display from snapping back after container resize
|
||||
- Removed unused code and fixed PR checks and lint warnings
|
||||
- Send name instruction for protocol >= 1.3.0 (guacd 1.5.0/1.1.0 errors)
|
||||
- Wire up OIDC to password link dialog with submit/visibility
|
||||
- Pass through command completion
|
||||
- Resolve terminal jump hosts server-side
|
||||
- Auto allow SSL certs for private network hosts
|
||||
- Removed deprecated host management button from command palette
|
||||
- Command palette opening wrong protocol
|
||||
- Export/import failing for ssh key hosts
|
||||
- Docker ssh2 native crypto not compiling in Docker
|
||||
- Persisted terminal tabs attempt SSH on RDP hosts afater migration
|
||||
- Credentials not appearing in host manager until refresh
|
||||
- File transfers over 100MB failing
|
||||
- File transfers over 30 seconds aborted by axios timeout
|
||||
- SSH terminal through jump host chain timing out with malformed SSH messages
|
||||
- Cloning a host with SSH key auth not allowing auth method change on the clone
|
||||
- File deletion in the file manager affecting selected files in inactive tabs
|
||||
- File manager not connecting when cert passphrase is not saved
|
||||
- File text editor cursor position lost on save
|
||||
- macOS Option + Left/Right Arrow outputting raw ANSI sequences instead of moving cursor by word
|
||||
- File manager tree view not sorted alphabetically
|
||||
- SFTP failing with timeout when using a jump host chain
|
||||
- SSH key auth with Duo not showing prompt and failing immediately
|
||||
- Enabling 2FA with password login disabled causing a login deadlock
|
||||
- Failed to disable 2FA even when providing correct TOTP or password
|
||||
- Arrow buttons not working in Midnight Commander on the Android app
|
||||
- Credential deploy command copy failing silently (clipboard API unavailable in some browsers)
|
||||
- Disabling password login still showing the password login form
|
||||
- Folder picker in the new host form not showing existing folders
|
||||
- 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 -->
|
||||
|
||||
@@ -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, "");
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
project_id: "858252"
|
||||
api_token: "env:CROWDIN_API_TOKEN"
|
||||
|
||||
files:
|
||||
- source: /src/ui/locales/en.json
|
||||
translation: /src/ui/locales/translated/%locale_with_underscore%.json
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-slim AS deps
|
||||
FROM node:26-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:24-slim AS production-deps
|
||||
FROM node:26-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:24-slim
|
||||
FROM node:26-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl 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 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
|
||||
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
mkdir -p /tmp/nginx
|
||||
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
|
||||
|
||||
if [ -w /app/data ]; then
|
||||
|
||||
+104
-37
@@ -24,7 +24,11 @@ http {
|
||||
client_header_timeout 300s;
|
||||
|
||||
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_recursive on;
|
||||
|
||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
@@ -58,6 +62,8 @@ http {
|
||||
listen ${SSL_PORT} ssl;
|
||||
server_name _;
|
||||
|
||||
absolute_redirect off;
|
||||
|
||||
ssl_certificate ${SSL_CERT_PATH};
|
||||
ssl_certificate_key ${SSL_KEY_PATH};
|
||||
|
||||
@@ -65,6 +71,12 @@ http {
|
||||
add_header X-Content-Type-Options nosniff 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 {
|
||||
root /app/html;
|
||||
expires off;
|
||||
@@ -126,7 +138,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
@@ -135,7 +147,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
@@ -144,7 +156,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
@@ -153,7 +165,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
@@ -162,7 +174,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -175,7 +187,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||
@@ -184,7 +196,16 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/audit-logs(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
@@ -193,7 +214,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/open-tabs(/.*)?$ {
|
||||
@@ -202,7 +223,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/user-preferences(/.*)?$ {
|
||||
@@ -211,7 +232,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
@@ -223,7 +244,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -242,7 +263,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -258,7 +279,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/quick-connect {
|
||||
@@ -270,7 +291,7 @@ http {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||
@@ -309,7 +330,25 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /session_logs/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /tailscale/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/websocket/ {
|
||||
@@ -348,7 +387,7 @@ http {
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
@@ -368,7 +407,7 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
@@ -377,7 +416,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
@@ -388,7 +427,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
@@ -401,7 +440,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/pinned {
|
||||
@@ -410,7 +449,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/shortcuts {
|
||||
@@ -419,7 +458,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/sudo-password {
|
||||
@@ -428,7 +467,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ {
|
||||
@@ -442,7 +481,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -463,7 +502,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -479,7 +518,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /health {
|
||||
@@ -488,7 +527,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/status(/.*)?$ {
|
||||
@@ -497,7 +536,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
@@ -506,7 +545,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
@@ -519,7 +558,20 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/host-metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location ~ ^/global-settings(/.*)?$ {
|
||||
@@ -528,7 +580,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
@@ -537,7 +589,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
@@ -546,16 +598,16 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||
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 $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
@@ -569,7 +621,7 @@ http {
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
@@ -583,13 +635,28 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# --- tmux-monitor begin ---
|
||||
location ~ ^/tmux_monitor(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30010;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
# --- tmux-monitor end ---
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
+116
-37
@@ -24,7 +24,11 @@ http {
|
||||
client_header_timeout 300s;
|
||||
|
||||
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_recursive on;
|
||||
|
||||
map $http_x_forwarded_proto $proxy_x_forwarded_proto {
|
||||
default $http_x_forwarded_proto;
|
||||
@@ -51,9 +55,17 @@ http {
|
||||
listen ${PORT};
|
||||
server_name _;
|
||||
|
||||
absolute_redirect off;
|
||||
|
||||
add_header X-Content-Type-Options nosniff 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 {
|
||||
root /app/html;
|
||||
expires off;
|
||||
@@ -115,7 +127,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/releases(/.*)?$ {
|
||||
@@ -124,7 +136,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/alerts(/.*)?$ {
|
||||
@@ -133,7 +145,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
@@ -142,7 +154,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
@@ -151,7 +163,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -164,7 +176,19 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/proxmox(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||
@@ -173,7 +197,16 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/audit-logs(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/terminal(/.*)?$ {
|
||||
@@ -182,7 +215,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/open-tabs(/.*)?$ {
|
||||
@@ -191,7 +224,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/user-preferences(/.*)?$ {
|
||||
@@ -200,7 +233,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/database(/.*)?$ {
|
||||
@@ -212,7 +245,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -231,7 +264,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -247,7 +280,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/quick-connect {
|
||||
@@ -259,7 +292,7 @@ http {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||
@@ -298,7 +331,25 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /session_logs/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /tailscale/ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/websocket/ {
|
||||
@@ -337,7 +388,7 @@ http {
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
@@ -357,7 +408,7 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
@@ -366,7 +417,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/tunnel/ {
|
||||
@@ -377,7 +428,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
@@ -390,7 +441,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/pinned {
|
||||
@@ -399,7 +450,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/shortcuts {
|
||||
@@ -408,7 +459,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/file_manager/sudo-password {
|
||||
@@ -417,7 +468,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /ssh/file_manager/ {
|
||||
@@ -431,7 +482,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -452,7 +503,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -468,7 +519,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /health {
|
||||
@@ -477,7 +528,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/status(/.*)?$ {
|
||||
@@ -486,7 +537,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/metrics(/.*)?$ {
|
||||
@@ -495,7 +546,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
@@ -508,7 +559,20 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/host-metrics(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30005;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
location ~ ^/global-settings(/.*)?$ {
|
||||
@@ -517,7 +581,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/uptime(/.*)?$ {
|
||||
@@ -526,7 +590,7 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/activity(/.*)?$ {
|
||||
@@ -535,16 +599,16 @@ http {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/dashboard/preferences(/.*)?$ {
|
||||
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 $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
@@ -558,7 +622,7 @@ http {
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
|
||||
@@ -572,13 +636,28 @@ http {
|
||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||
}
|
||||
|
||||
# --- tmux-monitor begin ---
|
||||
location ~ ^/tmux_monitor(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30010;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
# --- tmux-monitor end ---
|
||||
|
||||
location ~ ^/docker(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30007;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"artifactName": "termix_macos_${arch}_dmg.${ext}",
|
||||
"sign": true
|
||||
},
|
||||
"afterPack": "build/after-pack.cjs",
|
||||
"afterSign": "build/notarize.cjs",
|
||||
"mas": {
|
||||
"provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
|
||||
|
||||
@@ -20,6 +20,32 @@ const { URL } = require("url");
|
||||
const { fork } = require("child_process");
|
||||
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 electronAuthCookiesPath = path.join(
|
||||
app.getPath("userData"),
|
||||
@@ -476,6 +502,15 @@ if (process.platform === "linux") {
|
||||
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
||||
|
||||
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") {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
if (isInsecureModeEnabled()) {
|
||||
|
||||
Generated
+1552
-1826
File diff suppressed because it is too large
Load Diff
+46
-41
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.1",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -41,10 +41,11 @@
|
||||
"build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
"@types/ldapjs": "^3.0.6",
|
||||
"axios": "^1.18.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"body-parser": "^2.3.0",
|
||||
"chalk": "^5.6.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
@@ -53,49 +54,53 @@
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"jose": "^6.2.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapjs": "^3.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"multer": "^2.1.1",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^5.1.9",
|
||||
"qrcode": "^1.5.4",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^7.0.0",
|
||||
"undici": "^8.5.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
"@electron/notarize": "^3.1.1",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@eslint/js": "^9.0.0",
|
||||
"@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",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-accordion": "^1.2.13",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||
"@radix-ui/react-checkbox": "^1.3.4",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-popover": "^1.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.9",
|
||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slider": "^1.4.1",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.1",
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -109,9 +114,9 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
@@ -131,9 +136,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"electron": "^42.2.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"cytoscape": "^3.34.0",
|
||||
"electron": "^42.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -141,18 +146,18 @@
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"lucide-react": "^1.11.0",
|
||||
"lint-staged": "^17.0.7",
|
||||
"lucide-react": "^1.20.0",
|
||||
"prettier": "3.8.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.7",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"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-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -161,14 +166,14 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.35.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "^8.0.13",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**مدير مضيفات SSH:**
|
||||
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
|
||||
حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات (مع دعم تخصيص المجلدات والمجلدات المتداخلة)، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**إحصائيات الخادم:**
|
||||
عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux.
|
||||
**مقاييس المضيف:**
|
||||
عرض استخدام المعالج والذاكرة والقرص والشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ وعارض السجلات والمستخدمين/الصلاحيات والشهادات وغيرها الكثير، تعمل على معظم الخوادم المبنية على Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**مصادقة المستخدمين:**
|
||||
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
|
||||
إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC/LDAP/SSO (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. عرض سجل تدقيق لجميع إجراءات المستخدمين.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
||||
- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً
|
||||
- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
|
||||
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
|
||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، إلخ.
|
||||
- **تكامل Proxmox** - إضافة المضيفات تلقائياً إلى Termix من نسخة Proxmox الخاصة بك
|
||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، tmux، port knocking، تسجيل الطرفية، إلخ.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH 主机管理器:**
|
||||
通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
|
||||
通过标签和文件夹(支持文件夹自定义和嵌套文件夹)保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**服务器统计:**
|
||||
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况以及网络、运行时间、系统信息、防火墙和端口监控。
|
||||
**主机指标:**
|
||||
在大多数基于 Linux 的服务器上查看 CPU、内存、磁盘使用情况、网络、运行时间、系统信息、防火墙、端口监控、日志查看器、用户/权限、证书等更多信息。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**用户认证:**
|
||||
安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
|
||||
安全的用户管理,具有管理员控制、OIDC/LDAP/SSO(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。查看所有用户操作的审计日志。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
- **命令历史** - 自动完成并查看之前运行过的 SSH 命令
|
||||
- **快速连接** - 无需保存连接数据即可连接到服务器
|
||||
- **命令面板** - 双击左 Shift 键即可通过键盘快速访问 SSH 连接
|
||||
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击等
|
||||
- **Proxmox 集成** - 从您的 Proxmox 实例自动将主机添加到 Termix
|
||||
- **丰富的 SSH 功能** - 支持跳转主机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、端口敲击、终端日志记录等
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH-Host-Manager:**
|
||||
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
|
||||
Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern (Ordneranpassung und verschachtelte Ordner werden unterstutzt) und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Moglichkeit, die Bereitstellung von SSH-Schlusseln zu automatisieren.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Serverstatistiken:**
|
||||
CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen.
|
||||
**Host-Metriken:**
|
||||
CPU-, Arbeitsspeicher- und Festplattenauslastung, Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor, Log-Viewer, Benutzer/Berechtigungen, Zertifikate und vieles mehr auf den meisten Linux-basierten Servern anzeigen.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Benutzerauthentifizierung:**
|
||||
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen.
|
||||
Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC-/LDAP-/SSO-Unterstutzung (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstutzung. Aktive Benutzersitzungen uber alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknupfen. Audit-Protokoll aller Benutzeraktionen anzeigen.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://
|
||||
- **Befehlsverlauf** - Autovervollstandigung und Anzeige zuvor ausgefuhrter SSH-Befehle
|
||||
- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu mussen
|
||||
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
|
||||
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking usw.
|
||||
- **Proxmox-Integration** - Automatisches Hinzufugen von Hosts zu Termix aus Ihrer Proxmox-Instanz
|
||||
- **SSH-Funktionsreich** - Unterstutzt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfullen von Passwortern, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, Port Knocking, Terminal-Protokollierung usw.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores.
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestor de Hosts SSH:**
|
||||
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
|
||||
Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas (con personalizacion de carpetas y soporte de carpetas anidadas), y guarde facilmente informacion de inicio de sesion reutilizable con la capacidad de automatizar el despliegue de claves SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Estadisticas del Servidor:**
|
||||
Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos en la mayoria de los servidores basados en Linux.
|
||||
**Metricas del Host:**
|
||||
Vea el uso de CPU, memoria y disco, red, tiempo de actividad, informacion del sistema, firewall, monitor de puertos, visor de registros, usuarios/permisos, certificados y muchos mas en la mayoria de los servidores basados en Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Autenticacion de Usuarios:**
|
||||
Gestion segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si.
|
||||
Gestion segura de usuarios con controles de administrador y soporte para OIDC/LDAP/SSO (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre si. Vea el registro de auditoria de las acciones de todos los usuarios.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](http
|
||||
- **Historial de Comandos** - Autocompletado y visualizacion de comandos SSH ejecutados anteriormente
|
||||
- **Conexion Rapida** - Conectese a un servidor sin necesidad de guardar los datos de conexion
|
||||
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rapidamente a las conexiones SSH con su teclado
|
||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Integracion con Proxmox** - Agregue automaticamente hosts a Termix desde su instancia de Proxmox
|
||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificacion de clave de host, autocompletado de contrasenas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les stat
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestionnaire d'hotes SSH:**
|
||||
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
|
||||
Enregistrez, organisez et gerez vos connexions SSH avec des tags et des dossiers (personnalisation des dossiers et prise en charge des dossiers imbriques), et sauvegardez facilement les informations de connexion reutilisables tout en automatisant le deploiement des cles SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Statistiques serveur:**
|
||||
Visualisez l'utilisation du CPU, de la memoire et du disque ainsi que le reseau, le temps de fonctionnement, les informations systeme, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux.
|
||||
**Metriques d'hote:**
|
||||
Visualisez l'utilisation du CPU, de la memoire, du disque, le reseau, le temps de fonctionnement, les informations systeme, le pare-feu, le moniteur de ports, le visualiseur de journaux, les utilisateurs/permissions, les certificats et bien plus encore sur la plupart des serveurs Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Authentification des utilisateurs:**
|
||||
Gestion securisee des utilisateurs avec controles administrateur et support OIDC (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble.
|
||||
Gestion securisee des utilisateurs avec controles administrateur et support OIDC/LDAP/SSO (avec controle d'acces) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et revoquez les permissions. Liez vos comptes OIDC/locaux ensemble. Consultez le journal d'audit des actions de tous les utilisateurs.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.sit
|
||||
- **Historique des commandes** - Auto-completion et consultation des commandes SSH precedemment executees
|
||||
- **Connexion rapide** - Connectez-vous a un serveur sans avoir a sauvegarder les donnees de connexion
|
||||
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour acceder rapidement aux connexions SSH avec votre clavier
|
||||
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Integration Proxmox** - Ajoutez automatiquement des hotes dans Termix depuis votre instance Proxmox
|
||||
- **SSH riche en fonctionnalites** - Support des hotes de rebond, Warpgate, connexions basees sur TOTP, SOCKS5, verification des cles d'hote, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, journalisation du terminal, etc.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH होस्ट मैनेजर:**
|
||||
टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
|
||||
टैग और फ़ोल्डर (फ़ोल्डर कस्टमाइज़ेशन और नेस्टेड फ़ोल्डर सपोर्ट के साथ) के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**सर्वर आँकड़े:**
|
||||
अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें।
|
||||
**होस्ट मेट्रिक्स:**
|
||||
अधिकांश Linux आधारित सर्वर पर CPU, मेमोरी, डिस्क उपयोग, नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर, लॉग व्यूअर, उपयोगकर्ता/अनुमतियाँ, सर्टिफ़िकेट और भी बहुत कुछ देखें।
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**उपयोगकर्ता प्रमाणीकरण:**
|
||||
व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
|
||||
व्यवस्थापक नियंत्रण और OIDC/LDAP/SSO (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। सभी उपयोगकर्ताओं की कार्रवाइयों का ऑडिट लॉग देखें।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
||||
- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य
|
||||
- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
|
||||
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
|
||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग आदि का सपोर्ट
|
||||
- **Proxmox एकीकरण** - अपने Proxmox इंस्टेंस से Termix में होस्ट स्वचालित रूप से जोड़ें
|
||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, पोर्ट नॉकिंग, टर्मिनल लॉगिंग आदि का सपोर्ट
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei c
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestore Host SSH:**
|
||||
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
|
||||
Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle (con personalizzazione delle cartelle e supporto per cartelle annidate), salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Statistiche Server:**
|
||||
Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux.
|
||||
**Metriche Host:**
|
||||
Visualizza l'utilizzo di CPU, memoria, disco, rete, uptime, informazioni di sistema, firewall, monitoraggio porte, visualizzatore di log, utenti/permessi, certificati e molto altro sulla maggior parte dei server basati su Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Autenticazione Utente:**
|
||||
Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
|
||||
Gestione utenti sicura con controlli amministrativi e supporto OIDC/LDAP/SSO (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. Visualizza il log di controllo delle azioni di tutti gli utenti.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix
|
||||
- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza
|
||||
- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
|
||||
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
|
||||
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ecc.
|
||||
- **Integrazione Proxmox** - Aggiungi automaticamente host a Termix dalla tua istanza Proxmox
|
||||
- **SSH Ricco di Funzionalita** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registrazione terminale, ecc.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSHホストマネージャー:**
|
||||
タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
|
||||
タグやフォルダ(フォルダのカスタマイズとネストフォルダ対応)でSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**サーバー統計:**
|
||||
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニターを表示できます。
|
||||
**ホストメトリクス:**
|
||||
ほとんどのLinuxベースのサーバーで、CPU、メモリ、ディスク使用量、ネットワーク、アップタイム、システム情報、ファイアウォール、ポートモニター、ログビューア、ユーザー/権限、証明書など、さらに多くの情報を表示できます。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**ユーザー認証:**
|
||||
管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
|
||||
管理者コントロールとOIDC/LDAP/SSO(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。すべてのユーザー操作の監査ログを表示できます。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
||||
- **コマンド履歴** - 過去に実行したSSHコマンドの自動補完と表示が可能です
|
||||
- **クイック接続** - 接続データを保存せずにサーバーに接続できます
|
||||
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセスできます
|
||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)などに対応しています
|
||||
- **Proxmox統合** - Proxmoxインスタンスからホストを自動的にTermixに追加できます
|
||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)、tmux、ポート敲き(port knocking)、ターミナルログ記録などに対応しています
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH 호스트 관리자:**
|
||||
태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
|
||||
태그와 폴더(폴더 사용자 지정 및 중첩 폴더 지원)로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**서버 통계:**
|
||||
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시.
|
||||
**호스트 메트릭:**
|
||||
대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량, 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터, 로그 뷰어, 사용자/권한, 인증서 등 다양한 정보를 표시.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**사용자 인증:**
|
||||
관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
|
||||
관리자 제어와 OIDC/LDAP/SSO(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. 모든 사용자 작업의 감사 로그 조회.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
||||
- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회
|
||||
- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
|
||||
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
|
||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹 등 지원
|
||||
- **Proxmox 통합** - Proxmox 인스턴스에서 Termix로 호스트를 자동 추가
|
||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, 포트 노킹, 터미널 로깅 등 지원
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres.
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gerenciador de Hosts SSH:**
|
||||
Salve, organize e gerencie suas conexoes SSH com tags e pastas, e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
|
||||
Salve, organize e gerencie suas conexoes SSH com tags e pastas (com personalizacao de pastas e suporte a pastas aninhadas), e salve facilmente informacoes de login reutilizaveis com a capacidade de automatizar a implantacao de chaves SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Estatisticas do Servidor:**
|
||||
Visualize o uso de CPU, memoria e disco junto com rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux.
|
||||
**Metricas do Host:**
|
||||
Visualize o uso de CPU, memoria e disco, rede, tempo de atividade, informacoes do sistema, firewall, monitor de portas, visualizador de logs, usuarios/permissoes, certificados e muito mais na maioria dos servidores baseados em Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Autenticacao de Usuarios:**
|
||||
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si.
|
||||
Gerenciamento seguro de usuarios com controles de administrador e suporte para OIDC/LDAP/SSO (com controle de acesso) e 2FA (TOTP). Visualize sessoes ativas de usuarios em todas as plataformas e revogue permissoes. Vincule suas contas OIDC/Locais entre si. Visualize o log de auditoria de todas as acoes dos usuarios.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](htt
|
||||
- **Historico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
|
||||
- **Conexao Rapida** - Conecte-se a um servidor sem precisar salvar os dados de conexao
|
||||
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexoes SSH com seu teclado
|
||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, etc.
|
||||
- **Integracao com Proxmox** - Adicione automaticamente hosts ao Termix a partir da sua instancia Proxmox
|
||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexoes baseadas em TOTP, SOCKS5, verificacao de chave do host, preenchimento automatico de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, registro de terminal, etc.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Termix - это платформа для управления серверам
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Менеджер SSH-хостов:**
|
||||
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
|
||||
Сохранение, организация и управление SSH-подключениями с помощью тегов и папок (с настройкой папок и поддержкой вложенных папок), с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Статистика сервера:**
|
||||
Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux.
|
||||
**Метрики хоста:**
|
||||
Просмотр использования CPU, памяти и диска, сети, времени работы, информации о системе, файрвола, монитора портов, просмотрщика логов, пользователей/прав доступа, сертификатов и многого другого на большинстве серверов на базе Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Аутентификация пользователей:**
|
||||
Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
|
||||
Безопасное управление пользователями с административным контролем и поддержкой OIDC/LDAP/SSO (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. Просмотр журнала аудита действий всех пользователей.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ SSH-сессии и вкладки остаются открытыми на вс
|
||||
- **История команд** - Автодополнение и просмотр ранее выполненных SSH-команд
|
||||
- **Быстрое подключение** - Подключение к серверу без необходимости сохранения данных подключения
|
||||
- **Командная палитра** - Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
|
||||
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking и др.
|
||||
- **Интеграция с Proxmox** - Автоматическое добавление хостов в Termix из вашего экземпляра Proxmox
|
||||
- **Богатый функционал SSH** - Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, логирования терминала и др.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistikle
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**SSH Ana Bilgisayar Yoneticisi:**
|
||||
SSH baglantilarinizi etiketler ve klasorlerle kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
|
||||
SSH baglantilarinizi etiketler ve klasorlerle (klasor ozellestirme ve ic ice klasor destegi ile) kaydedin, duzenleyin ve yonetin; yeniden kullanilabilir giris bilgilerini kolayca kaydedin ve SSH anahtarlarinin dagitimini otomatiklestirin.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Sunucu Istatistikleri:**
|
||||
Cogu Linux tabanli sunucularda CPU, bellek ve disk kullanimini ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme ile birlikte goruntuleyin.
|
||||
**Ana Bilgisayar Metrikleri:**
|
||||
Cogu Linux tabanli sunucularda CPU, bellek, disk kullanimi, ag, calisma suresi, sistem bilgisi, guvenlik duvari, port izleme, gunluk goruntuleyici, kullanicilar/izinler, sertifikalar ve daha fazlasini goruntuleyin.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Kullanici Kimlik Dogrulama:**
|
||||
Yonetici kontrolleri, OIDC (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin.
|
||||
Yonetici kontrolleri, OIDC/LDAP/SSO (erisim kontrollu) ve 2FA (TOTP) destegi ile guvenli kullanici yonetimi. Tum platformlardaki aktif kullanici oturumlarini goruntuleyin ve izinleri iptal edin. OIDC/Yerel hesaplarinizi birbirine baglayin. Tum kullanicilarin islemlerinin denetim gunlugunu goruntuleyin.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/transla
|
||||
- **Komut Gecmisi** - Daha once calistirilan SSH komutlarini otomatik tamamlayin ve goruntuleyin
|
||||
- **Hizli Baglanti** - Baglanti verilerini kaydetmeden bir sunucuya baglanin
|
||||
- **Komut Paleti** - Sol shift tusuna iki kez basarak SSH baglantilariniza klavyenizle hizlica erisin
|
||||
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking vb. destekler.
|
||||
- **Proxmox Entegrasyonu** - Proxmox ornekinizden Termix'e otomatik olarak ana bilgisayar ekleyin
|
||||
- **SSH Zengin Ozellikler** - Atlama ana bilgisayarlari, Warpgate, TOTP tabanli baglantilar, SOCKS5, ana bilgisayar anahtar dogrulama, otomatik sifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal gunlukleme vb. destekler.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-5
@@ -94,21 +94,21 @@ Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien con
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Trinh Quan Ly May Chu SSH:**
|
||||
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc, de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
|
||||
Luu, sap xep va quan ly cac ket noi SSH cua ban voi the va thu muc (ho tro tuy chinh thu muc va thu muc long nhau), de dang luu thong tin dang nhap co the tai su dung dong thoi co the tu dong hoa viec trien khai khoa SSH.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Thong Ke May Chu:**
|
||||
Xem muc su dung CPU, bo nho va o dia cung voi mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong tren hau het cac may chu chay Linux.
|
||||
**Chi So May Chu:**
|
||||
Xem muc su dung CPU, bo nho, o dia, mang, thoi gian hoat dong, thong tin he thong, tuong lua, giam sat cong, trinh xem nhat ky, nguoi dung/quyen, chung chi va nhieu hon nua tren hau het cac may chu chay Linux.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Xac Thuc Nguoi Dung:**
|
||||
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau.
|
||||
Quan ly nguoi dung an toan voi quyen quan tri va ho tro OIDC/LDAP/SSO (co kiem soat truy cap) va 2FA (TOTP). Xem phien hoat dong cua nguoi dung tren tat ca cac nen tang va thu hoi quyen. Lien ket tai khoan OIDC/Noi bo cua ban voi nhau. Xem nhat ky kiem toan cac hanh dong cua tat ca nguoi dung.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -170,7 +170,8 @@ Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.term
|
||||
- **Lich Su Lenh** - Tu dong hoan thanh va xem cac lenh SSH da chay truoc do
|
||||
- **Ket Noi Nhanh** - Ket noi den may chu ma khong can luu du lieu ket noi
|
||||
- **Bang Lenh** - Nhan dup phim shift trai de truy cap nhanh cac ket noi SSH bang ban phim
|
||||
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, v.v.
|
||||
- **Tich Hop Proxmox** - Tu dong them may chu vao Termix tu instance Proxmox cua ban
|
||||
- **SSH Giau Tinh Nang** - Ho tro jump host, Warpgate, ket noi dua tren TOTP, SOCKS5, xac minh khoa may chu, tu dong dien mat khau, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, ghi nhat ky terminal, v.v.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 534 KiB |
@@ -0,0 +1,121 @@
|
||||
const API = "https://api.crowdin.com/api/v2";
|
||||
const ENGINE_ID = 649248; // Google Translate MT engine
|
||||
const PROJECT_NAME = "termix-ssh";
|
||||
const SOURCE_FILE = "en.json";
|
||||
|
||||
function token() {
|
||||
const t = process.env.CROWDIN_API_KEY;
|
||||
if (!t) throw new Error("CROWDIN_API_KEY is not set");
|
||||
return t;
|
||||
}
|
||||
|
||||
async function request(method, pathname, body) {
|
||||
const res = await fetch(`${API}${pathname}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function paged(pathname) {
|
||||
const items = [];
|
||||
let offset = 0;
|
||||
const limit = 500;
|
||||
for (;;) {
|
||||
const sep = pathname.includes("?") ? "&" : "?";
|
||||
const page = await request(
|
||||
"GET",
|
||||
`${pathname}${sep}limit=${limit}&offset=${offset}`,
|
||||
);
|
||||
const batch = (page.data || []).map((row) => row.data);
|
||||
items.push(...batch);
|
||||
if (batch.length < limit) break;
|
||||
offset += limit;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function resolveProjectId() {
|
||||
const projects = await paged("/projects");
|
||||
const match = projects.find(
|
||||
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
|
||||
);
|
||||
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
|
||||
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
|
||||
}
|
||||
|
||||
async function resolveFileId(projectId) {
|
||||
const files = await paged(`/projects/${projectId}/files`);
|
||||
const match = files.find(
|
||||
(f) => f.name === SOURCE_FILE || f.path === `/${SOURCE_FILE}`,
|
||||
);
|
||||
if (!match)
|
||||
throw new Error(`source file "${SOURCE_FILE}" not found in project`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function pollPreTranslation(projectId, preTranslationId) {
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const { data } = await request(
|
||||
"GET",
|
||||
`/projects/${projectId}/pre-translations/${preTranslationId}`,
|
||||
);
|
||||
if (data.status === "finished") return data;
|
||||
if (data.status === "failed" || data.status === "canceled") {
|
||||
throw new Error(
|
||||
`pre-translation ${data.status} (progress ${data.progress}%)`,
|
||||
);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
throw new Error("pre-translation timed out after 10 minutes");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { id: projectId, targetLanguageIds } = await resolveProjectId();
|
||||
if (targetLanguageIds.length === 0) {
|
||||
throw new Error("project has no target languages configured");
|
||||
}
|
||||
|
||||
const fileId = await resolveFileId(projectId);
|
||||
|
||||
console.log(
|
||||
`Pre-translating project ${projectId}, file ${fileId}, ${targetLanguageIds.length} languages via MT engine ${ENGINE_ID}`,
|
||||
);
|
||||
|
||||
const { data } = await request(
|
||||
"POST",
|
||||
`/projects/${projectId}/pre-translations`,
|
||||
{
|
||||
languageIds: targetLanguageIds,
|
||||
fileIds: [fileId],
|
||||
method: "mt",
|
||||
engineId: ENGINE_ID,
|
||||
scope: "untranslated",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await pollPreTranslation(projectId, data.identifier);
|
||||
console.log(`Pre-translation finished (${result.progress}%)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveProjectId,
|
||||
resolveFileId,
|
||||
pollPreTranslation,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(`crowdin-pretranslate: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const { parseDevBranch } = require("./parse-dev-branch.cjs");
|
||||
|
||||
function compareSemver(a, b) {
|
||||
const pa = a.split(".").map(Number);
|
||||
const pb = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (pa[i] !== pb[i]) return pa[i] - pb[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function latestDevBranch(refs) {
|
||||
const versions = [];
|
||||
for (const ref of refs) {
|
||||
const name = ref.replace(/^refs\/heads\//, "").trim();
|
||||
if (!name) continue;
|
||||
try {
|
||||
versions.push({ name, version: parseDevBranch(name) });
|
||||
} catch {
|
||||
// not a dev-X.Y.Z branch, skip it
|
||||
}
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
throw new Error("no dev-X.Y.Z branches found");
|
||||
}
|
||||
|
||||
versions.sort((a, b) => compareSemver(a.version, b.version));
|
||||
return versions[versions.length - 1].name;
|
||||
}
|
||||
|
||||
module.exports = { latestDevBranch };
|
||||
|
||||
if (require.main === module) {
|
||||
const input = require("fs").readFileSync(0, "utf8");
|
||||
const refs = input.split("\n").filter(Boolean);
|
||||
try {
|
||||
process.stdout.write(latestDevBranch(refs) + "\n");
|
||||
} catch (err) {
|
||||
console.error(`latest-dev-branch: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { latestDevBranch } = require("./latest-dev-branch.cjs");
|
||||
|
||||
describe("latestDevBranch", () => {
|
||||
it("picks the highest semver dev branch", () => {
|
||||
expect(latestDevBranch(["dev-2.3.9", "dev-2.4.0", "dev-1.11.2"])).toBe(
|
||||
"dev-2.4.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("compares numerically, not lexically", () => {
|
||||
expect(latestDevBranch(["dev-2.10.0", "dev-2.9.0"])).toBe("dev-2.10.0");
|
||||
});
|
||||
|
||||
it("ignores main and non-dev branches", () => {
|
||||
expect(latestDevBranch(["main", "feature/x", "dev-1.0.0"])).toBe(
|
||||
"dev-1.0.0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips refs/heads/ prefixes", () => {
|
||||
expect(latestDevBranch(["refs/heads/dev-3.0.1"])).toBe("dev-3.0.1");
|
||||
});
|
||||
|
||||
it("throws when there are no dev branches", () => {
|
||||
expect(() => latestDevBranch(["main", "feature/x"])).toThrow(
|
||||
/no dev-X\.Y\.Z branches/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
function parseDevBranch(ref) {
|
||||
if (!ref || typeof ref !== "string") {
|
||||
throw new Error("a branch ref is required");
|
||||
}
|
||||
|
||||
const name = ref.replace(/^refs\/heads\//, "").trim();
|
||||
|
||||
if (!name.startsWith("dev-")) {
|
||||
throw new Error(
|
||||
`release must run from a dev branch, got "${name}" (expected dev-X.Y.Z)`,
|
||||
);
|
||||
}
|
||||
|
||||
const version = name.slice("dev-".length);
|
||||
if (!SEMVER.test(version)) {
|
||||
throw new Error(
|
||||
`branch "${name}" does not contain a valid semver version (got "${version}")`,
|
||||
);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
module.exports = { parseDevBranch };
|
||||
|
||||
if (require.main === module) {
|
||||
const ref = process.argv[2] || process.env.GITHUB_REF;
|
||||
try {
|
||||
process.stdout.write(parseDevBranch(ref) + "\n");
|
||||
} catch (err) {
|
||||
console.error(`parse-dev-branch: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { parseDevBranch } = require("./parse-dev-branch.cjs");
|
||||
|
||||
describe("parseDevBranch", () => {
|
||||
it("extracts the version from a dev branch name", () => {
|
||||
expect(parseDevBranch("dev-2.4.0")).toBe("2.4.0");
|
||||
});
|
||||
|
||||
it("strips a refs/heads/ prefix", () => {
|
||||
expect(parseDevBranch("refs/heads/dev-1.11.2")).toBe("1.11.2");
|
||||
});
|
||||
|
||||
it("rejects the main branch", () => {
|
||||
expect(() => parseDevBranch("main")).toThrow(/dev branch/);
|
||||
});
|
||||
|
||||
it("rejects a dev branch without a semver version", () => {
|
||||
expect(() => parseDevBranch("dev-foo")).toThrow(/valid semver/);
|
||||
});
|
||||
|
||||
it("rejects a partial version", () => {
|
||||
expect(() => parseDevBranch("dev-2.4")).toThrow(/valid semver/);
|
||||
});
|
||||
|
||||
it("rejects an empty ref", () => {
|
||||
expect(() => parseDevBranch("")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function extractYoutubeId(notes) {
|
||||
const match = notes.match(
|
||||
/<!--\s*YOUTUBE\s*-->([\s\S]*?)<!--\s*\/YOUTUBE\s*-->/,
|
||||
);
|
||||
if (!match || !match[1].trim()) {
|
||||
throw new Error(
|
||||
"missing or empty <!-- YOUTUBE --> section in release notes",
|
||||
);
|
||||
}
|
||||
return parseYoutubeId(match[1].trim());
|
||||
}
|
||||
|
||||
function parseYoutubeId(raw) {
|
||||
const value = raw.trim();
|
||||
let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
m = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
||||
throw new Error(`could not parse a YouTube video id from "${value}"`);
|
||||
}
|
||||
|
||||
async function getAccessToken({ clientId, clientSecret, refreshToken }) {
|
||||
const res = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`token exchange failed (${res.status}): ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
async function setVideoPublic(accessToken, videoId) {
|
||||
const res = await fetch(
|
||||
"https://www.googleapis.com/youtube/v3/videos?part=status",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: videoId,
|
||||
status: { privacyStatus: "public" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`videos.update failed (${res.status}): ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const clientId = process.env.YOUTUBE_CLIENT_ID;
|
||||
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET;
|
||||
const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN;
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) {
|
||||
throw new Error(
|
||||
"YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required",
|
||||
);
|
||||
}
|
||||
|
||||
const notesPath = path.resolve(
|
||||
process.env.RELEASE_NOTES || "RELEASE_NOTES.md",
|
||||
);
|
||||
const notes = fs.readFileSync(notesPath, "utf8");
|
||||
const videoId = extractYoutubeId(notes);
|
||||
|
||||
const accessToken = await getAccessToken({
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
});
|
||||
await setVideoPublic(accessToken, videoId);
|
||||
|
||||
console.log(`Set YouTube video ${videoId} to public.`);
|
||||
}
|
||||
|
||||
module.exports = { extractYoutubeId, parseYoutubeId };
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(`publish-youtube: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs");
|
||||
|
||||
describe("parseYoutubeId", () => {
|
||||
it("parses a youtu.be short link", () => {
|
||||
expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("parses a watch?v= link", () => {
|
||||
expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe(
|
||||
"At8iDk6-Q_s",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses an embed link", () => {
|
||||
expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe(
|
||||
"At8iDk6-Q_s",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a bare id", () => {
|
||||
expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("throws on garbage", () => {
|
||||
expect(() => parseYoutubeId("https://example.com/")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractYoutubeId", () => {
|
||||
it("pulls the id from a release notes YOUTUBE section", () => {
|
||||
const notes = [
|
||||
"<!-- SUMMARY -->",
|
||||
"stuff",
|
||||
"<!-- /SUMMARY -->",
|
||||
"<!-- YOUTUBE -->",
|
||||
"https://youtu.be/At8iDk6-Q_s",
|
||||
"<!-- /YOUTUBE -->",
|
||||
].join("\n");
|
||||
expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s");
|
||||
});
|
||||
|
||||
it("throws when the YOUTUBE section is missing", () => {
|
||||
expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/);
|
||||
});
|
||||
|
||||
it("throws when the YOUTUBE section is empty", () => {
|
||||
expect(() =>
|
||||
extractYoutubeId("<!-- YOUTUBE -->\n\n<!-- /YOUTUBE -->"),
|
||||
).toThrow(/empty/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
function readJsonWithTrailingNewline(filePath) {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
return { data: JSON.parse(raw), hadTrailingNewline: raw.endsWith("\n") };
|
||||
}
|
||||
|
||||
function writeJson(filePath, data, hadTrailingNewline) {
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
hadTrailingNewline ? serialized + "\n" : serialized,
|
||||
);
|
||||
}
|
||||
|
||||
function syncVersion(version, options = {}) {
|
||||
if (!SEMVER.test(version || "")) {
|
||||
throw new Error(`invalid version "${version}" (expected X.Y.Z)`);
|
||||
}
|
||||
|
||||
const root = options.root || path.join(__dirname, "..");
|
||||
const pkgPath = path.join(root, "package.json");
|
||||
const lockPath = path.join(root, "package-lock.json");
|
||||
|
||||
const changed = [];
|
||||
|
||||
const pkg = readJsonWithTrailingNewline(pkgPath);
|
||||
if (pkg.data.version !== version) {
|
||||
pkg.data.version = version;
|
||||
writeJson(pkgPath, pkg.data, pkg.hadTrailingNewline);
|
||||
changed.push("package.json");
|
||||
}
|
||||
|
||||
if (fs.existsSync(lockPath)) {
|
||||
const lock = readJsonWithTrailingNewline(lockPath);
|
||||
let lockChanged = false;
|
||||
|
||||
if (lock.data.version !== version) {
|
||||
lock.data.version = version;
|
||||
lockChanged = true;
|
||||
}
|
||||
if (lock.data.packages && lock.data.packages[""]) {
|
||||
if (lock.data.packages[""].version !== version) {
|
||||
lock.data.packages[""].version = version;
|
||||
lockChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (lockChanged) {
|
||||
writeJson(lockPath, lock.data, lock.hadTrailingNewline);
|
||||
changed.push("package-lock.json");
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
module.exports = { syncVersion };
|
||||
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const idx = args.indexOf("--version");
|
||||
const version = idx !== -1 ? args[idx + 1] : undefined;
|
||||
|
||||
try {
|
||||
const changed = syncVersion(version);
|
||||
if (changed.length === 0) {
|
||||
console.log(`sync-version: already at ${version}, no change`);
|
||||
} else {
|
||||
console.log(`sync-version: set ${version} in ${changed.join(", ")}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`sync-version: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { syncVersion } = require("./sync-version.cjs");
|
||||
|
||||
let root: string;
|
||||
|
||||
function pkg() {
|
||||
return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
}
|
||||
function lock() {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(root, "package-lock.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "sync-version-"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "termix", version: "2.3.2" }, null, 2) + "\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package-lock.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "termix",
|
||||
version: "2.3.2",
|
||||
lockfileVersion: 3,
|
||||
packages: { "": { name: "termix", version: "2.3.2" } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("syncVersion", () => {
|
||||
it("updates package.json and both package-lock version fields", () => {
|
||||
const changed = syncVersion("2.4.0", { root });
|
||||
expect(changed).toEqual(["package.json", "package-lock.json"]);
|
||||
expect(pkg().version).toBe("2.4.0");
|
||||
expect(lock().version).toBe("2.4.0");
|
||||
expect(lock().packages[""].version).toBe("2.4.0");
|
||||
});
|
||||
|
||||
it("is idempotent when already at the target version", () => {
|
||||
syncVersion("2.4.0", { root });
|
||||
const changed = syncVersion("2.4.0", { root });
|
||||
expect(changed).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves the trailing newline", () => {
|
||||
syncVersion("2.4.0", { root });
|
||||
expect(fs.readFileSync(path.join(root, "package.json"), "utf8")).toMatch(
|
||||
/\}\n$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid version", () => {
|
||||
expect(() => syncVersion("2.4", { root })).toThrow(/invalid version/);
|
||||
});
|
||||
|
||||
it("works when only the lock root version is stale", () => {
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ name: "termix", version: "2.4.0" }, null, 2) + "\n",
|
||||
);
|
||||
const changed = syncVersion("2.4.0", { root });
|
||||
expect(changed).toEqual(["package-lock.json"]);
|
||||
});
|
||||
});
|
||||
+25
-161
@@ -1,18 +1,19 @@
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
hosts,
|
||||
hostAccess,
|
||||
dashboardPreferences,
|
||||
userRoles,
|
||||
} from "./database/db/schema.js";
|
||||
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
||||
import { eq, and, desc, inArray, or, isNull, gte, sql } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../types/index.js";
|
||||
import { dashboardServiceLinksRouter } from "./database/routes/dashboard-service-links-routes.js";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -232,11 +233,30 @@ app.post("/activity/log", async (req, res) => {
|
||||
);
|
||||
|
||||
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()
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.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) {
|
||||
@@ -354,163 +374,7 @@ app.delete("/activity/reset", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/preferences:
|
||||
* get:
|
||||
* summary: Get dashboard layout preferences
|
||||
* description: Returns the user's customized dashboard layout settings. If no preferences exist, returns default layout.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Dashboard preferences retrieved
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* cards:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* order:
|
||||
* type: integer
|
||||
* 401:
|
||||
* description: Session expired
|
||||
* 500:
|
||||
* description: Failed to get preferences
|
||||
*/
|
||||
app.get("/dashboard/preferences", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const preferences = await getDb()
|
||||
.select()
|
||||
.from(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
|
||||
if (preferences.length === 0) {
|
||||
const defaultLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1 },
|
||||
{ id: "recent_activity", enabled: true, order: 2 },
|
||||
{ id: "network_graph", enabled: false, order: 3 },
|
||||
{ id: "quick_actions", enabled: true, order: 4 },
|
||||
{ id: "server_stats", enabled: true, order: 5 },
|
||||
],
|
||||
};
|
||||
return res.json(defaultLayout);
|
||||
}
|
||||
|
||||
const layout = JSON.parse(preferences[0].layout as string);
|
||||
res.json(layout);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get dashboard preferences", err);
|
||||
res.status(500).json({ error: "Failed to get dashboard preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /dashboard/preferences:
|
||||
* post:
|
||||
* summary: Save dashboard layout preferences
|
||||
* description: Saves or updates the user's customized dashboard layout settings.
|
||||
* tags:
|
||||
* - Dashboard
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* cards:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* order:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences saved successfully
|
||||
* 400:
|
||||
* description: Invalid request body
|
||||
* 401:
|
||||
* description: Session expired
|
||||
* 500:
|
||||
* description: Failed to save preferences
|
||||
*/
|
||||
app.post("/dashboard/preferences", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { cards, mainWidthPct } = req.body;
|
||||
|
||||
if (!cards || !Array.isArray(cards)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid request body. Expected { cards: Array }",
|
||||
});
|
||||
}
|
||||
|
||||
const layoutObj: Record<string, unknown> = { cards };
|
||||
if (typeof mainWidthPct === "number") {
|
||||
layoutObj.mainWidthPct = mainWidthPct;
|
||||
}
|
||||
const layout = JSON.stringify(layoutObj);
|
||||
|
||||
const existing = await getDb()
|
||||
.select()
|
||||
.from(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
|
||||
if (existing.length > 0) {
|
||||
await getDb()
|
||||
.update(dashboardPreferences)
|
||||
.set({ layout, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
} else {
|
||||
await getDb().insert(dashboardPreferences).values({ userId, layout });
|
||||
}
|
||||
|
||||
await DatabaseSaveTrigger.triggerSave("dashboard_preferences_updated");
|
||||
|
||||
dashboardLogger.success("Dashboard preferences saved", {
|
||||
operation: "save_dashboard_preferences",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Dashboard preferences saved" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to save dashboard preferences", err);
|
||||
res.status(500).json({ error: "Failed to save dashboard preferences" });
|
||||
}
|
||||
});
|
||||
app.use("/service-links", dashboardServiceLinksRouter);
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
|
||||
@@ -10,11 +10,15 @@ import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import proxmoxRoutes from "./routes/proxmox.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1777,11 +1781,15 @@ app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/proxmox", proxmoxRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
@@ -1824,7 +1832,11 @@ if (frontendDist) {
|
||||
);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === "GET" && req.accepts("html")) {
|
||||
if (
|
||||
req.method === "GET" &&
|
||||
req.accepts("html") &&
|
||||
!req.headers.authorization
|
||||
) {
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
|
||||
|
||||
@@ -488,6 +488,62 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_metrics_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host
|
||||
ON host_metrics_preferences (user_id, host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_health_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
checks TEXT NOT NULL,
|
||||
interval_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_health_checks_user_host
|
||||
ON host_health_checks (user_id, host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_health_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
check_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
detail TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_host_health_history_lookup
|
||||
ON host_health_history (user_id, host_id, check_id, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
@@ -627,6 +683,18 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "language", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
@@ -664,6 +732,16 @@ const migrateSchema = () => {
|
||||
"enable_terminal",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_session_logging",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_command_history",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tunnel",
|
||||
@@ -676,6 +754,11 @@ const migrateSchema = () => {
|
||||
"enable_file_manager",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"scp_legacy",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
@@ -714,6 +797,17 @@ const migrateSchema = () => {
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_proxmox",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "proxmox_config", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_tmux_monitor",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||
@@ -999,30 +1093,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM dashboard_preferences LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create dashboard_preferences table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1133,6 +1203,14 @@ const migrateSchema = () => {
|
||||
{ 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_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) {
|
||||
@@ -1150,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.
|
||||
// Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
|
||||
const usernameDomainBackfills = [
|
||||
@@ -1183,6 +1278,25 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the legacy "stats" tab type to "host-metrics" so previously saved
|
||||
// open tabs restore correctly after the Server Stats -> Host Metrics rename.
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
|
||||
const result = sqlite
|
||||
.prepare(
|
||||
"UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info(
|
||||
`Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
|
||||
{ operation: "open_tabs_tab_type_migration" },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// user_open_tabs table not present yet; nothing to migrate.
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1382,6 +1496,79 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- tmux-monitor begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM tmux_session_tags LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tmux_session_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create tmux_session_tags table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild pre-release tables created without the host FK so deleting a
|
||||
// host cascades to its tags (SQLite cannot add a FK via ALTER TABLE).
|
||||
try {
|
||||
const tagFks = sqlite
|
||||
.prepare("PRAGMA foreign_key_list(tmux_session_tags)")
|
||||
.all() as Array<{ table: string; from: string }>;
|
||||
const hasHostFk = tagFks.some(
|
||||
(fk) => fk.from === "host_id" && fk.table === "ssh_data",
|
||||
);
|
||||
if (!hasHostFk) {
|
||||
sqlite.exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE tmux_session_tags_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO tmux_session_tags_new (id, user_id, host_id, session_name, tag, created_at)
|
||||
SELECT id, user_id, host_id, session_name, tag, created_at
|
||||
FROM tmux_session_tags
|
||||
WHERE user_id IN (SELECT id FROM users)
|
||||
AND host_id IN (SELECT id FROM ssh_data);
|
||||
DROP TABLE tmux_session_tags;
|
||||
ALTER TABLE tmux_session_tags_new RENAME TO tmux_session_tags;
|
||||
COMMIT;
|
||||
`);
|
||||
databaseLogger.info("Rebuilt tmux_session_tags with host FK", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
}
|
||||
} catch (rebuildError) {
|
||||
try {
|
||||
sqlite.exec("ROLLBACK;");
|
||||
} catch {
|
||||
// no transaction open
|
||||
}
|
||||
databaseLogger.warn("Failed to add host FK to tmux_session_tags", {
|
||||
operation: "schema_migration",
|
||||
error: rebuildError,
|
||||
});
|
||||
}
|
||||
// --- tmux-monitor end ---
|
||||
|
||||
try {
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
|
||||
@@ -1487,6 +1674,43 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'sso_migration_v1'")
|
||||
.get();
|
||||
if (!migrationDone) {
|
||||
const providerCount = (
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as { c: number }
|
||||
).c;
|
||||
if (providerCount === 0) {
|
||||
const legacyRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (legacyRow) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||
)
|
||||
.run("OIDC", legacyRow.value);
|
||||
databaseLogger.info("Migrated legacy oidc_config into sso_providers table", {
|
||||
operation: "sso_migration_v1",
|
||||
});
|
||||
}
|
||||
}
|
||||
sqlite
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('sso_migration_v1', 'true')")
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||
operation: "sso_migration_v1",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const users = sqliteTable("users", {
|
||||
|
||||
isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
|
||||
oidcIdentifier: text("oidc_identifier"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
clientId: text("client_id"),
|
||||
clientSecret: text("client_secret"),
|
||||
issuerUrl: text("issuer_url"),
|
||||
@@ -30,6 +31,21 @@ export const settings = sqliteTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = sqliteTable("sso_providers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
@@ -78,6 +94,7 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
useWarpgate: integer("use_warpgate", { mode: "boolean" }).notNull().default(false),
|
||||
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
||||
|
||||
password: text("password"),
|
||||
@@ -97,6 +114,12 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
enableTerminal: integer("enable_terminal", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -105,9 +128,13 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
scpLegacy: integer("scp_legacy", { mode: "boolean" }).notNull().default(false),
|
||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
enableTmuxMonitor: integer("enable_tmux_monitor", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -126,6 +153,10 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
dockerConfig: text("docker_config"),
|
||||
enableProxmox: integer("enable_proxmox", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxmoxConfig: text("proxmox_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
quickActions: text("quick_actions"),
|
||||
notes: text("notes"),
|
||||
@@ -139,17 +170,24 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
vncPort: integer("vnc_port").default(5900),
|
||||
telnetPort: integer("telnet_port").default(23),
|
||||
|
||||
rdpCredentialId: integer("rdp_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
rdpUser: text("rdp_user"),
|
||||
rdpPassword: text("rdp_password"),
|
||||
rdpDomain: text("rdp_domain"),
|
||||
rdpSecurity: text("rdp_security"),
|
||||
rdpIgnoreCert: integer("rdp_ignore_cert", { mode: "boolean" }).default(false),
|
||||
|
||||
vncCredentialId: integer("vnc_credential_id").references(() => sshCredentials.id, { onDelete: "set null" }),
|
||||
vncPassword: text("vnc_password"),
|
||||
vncUser: text("vnc_user"),
|
||||
|
||||
telnetUser: text("telnet_user"),
|
||||
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"),
|
||||
security: text("security"),
|
||||
@@ -164,6 +202,7 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||
|
||||
macAddress: text("mac_address"),
|
||||
wolBroadcastAddress: text("wol_broadcast_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
|
||||
hostKeyFingerprint: text("host_key_fingerprint"),
|
||||
@@ -441,21 +480,6 @@ export const networkTopology = sqliteTable("network_topology", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dashboardPreferences = sqliteTable("dashboard_preferences", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
layout: text("layout").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
@@ -680,7 +704,103 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
fontSize: text("font_size"),
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
storageMode: text("storage_mode"),
|
||||
commandAutocomplete: integer("command_autocomplete", { mode: "boolean" }),
|
||||
commandPaletteEnabled: integer("command_palette_enabled", { mode: "boolean" }),
|
||||
showHostTags: integer("show_host_tags", { mode: "boolean" }),
|
||||
hostTrayOnClick: integer("host_tray_on_click", { mode: "boolean" }),
|
||||
pinAppRail: integer("pin_app_rail", { mode: "boolean" }),
|
||||
foldersCollapsed: integer("folders_collapsed", { mode: "boolean" }),
|
||||
confirmSnippetExecution: integer("confirm_snippet_execution", { mode: "boolean" }),
|
||||
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
|
||||
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
|
||||
hiddenRailTabs: text("hidden_rail_tabs"),
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostMetricsPreferences = sqliteTable("host_metrics_preferences", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
|
||||
// plain JSON (no field-level encryption).
|
||||
layout: text("layout").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostHealthChecks = sqliteTable("host_health_checks", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
|
||||
checks: text("checks").notNull(),
|
||||
intervalSeconds: integer("interval_seconds").notNull().default(300),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const hostHealthHistory = sqliteTable("host_health_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
checkId: text("check_id").notNull(),
|
||||
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
ok: integer("ok", { mode: "boolean" }).notNull(),
|
||||
latencyMs: integer("latency_ms"),
|
||||
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 ---
|
||||
export const tmuxSessionTags = sqliteTable("tmux_session_tags", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
sessionName: text("session_name").notNull(),
|
||||
tag: text("tag").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- tmux-monitor end ---
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq, and, gte, lte, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { auditLogs, users } from "../db/schema.js";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
|
||||
export function registerAuditLogRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /audit-logs:
|
||||
* get:
|
||||
* summary: List audit logs
|
||||
* description: Returns paginated, filterable audit log entries. Admin only.
|
||||
* tags:
|
||||
* - Audit
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema: { type: integer, default: 1 }
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema: { type: integer, default: 50, maximum: 200 }
|
||||
* - in: query
|
||||
* name: userId
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: action
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: resourceType
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: success
|
||||
* schema: { type: string, enum: [true, false] }
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema: { type: string, format: date-time }
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema: { type: string, format: date-time }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Paginated list of audit logs.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to fetch audit logs.
|
||||
*/
|
||||
router.get("/audit-logs", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const adminUser = await db
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, authReq.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!adminUser[0]?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const page = Math.max(1, parseInt(String(req.query.page || "1"), 10));
|
||||
const limit = Math.min(
|
||||
200,
|
||||
Math.max(1, parseInt(String(req.query.limit || "50"), 10)),
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { userId, action, resourceType, success, startDate, endDate } =
|
||||
req.query as Record<string, string | undefined>;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (userId) conditions.push(eq(auditLogs.userId, userId));
|
||||
if (action) conditions.push(eq(auditLogs.action, action));
|
||||
if (resourceType)
|
||||
conditions.push(eq(auditLogs.resourceType, resourceType));
|
||||
if (success !== undefined && success !== "") {
|
||||
conditions.push(eq(auditLogs.success, success === "true"));
|
||||
}
|
||||
if (startDate) conditions.push(gte(auditLogs.timestamp, startDate));
|
||||
if (endDate) conditions.push(lte(auditLogs.timestamp, endDate));
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [logs, totalResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(whereClause)
|
||||
.orderBy(sql`${auditLogs.timestamp} DESC`)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(auditLogs)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const total = totalResult[0]?.count ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return res.json({ logs, total, page, totalPages });
|
||||
} catch (err) {
|
||||
apiLogger.error("Failed to fetch audit logs", err);
|
||||
return res.status(500).json({ error: "Failed to fetch audit logs" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /audit-logs/actions:
|
||||
* get:
|
||||
* summary: List distinct audit log action types
|
||||
* description: Returns all distinct action values in the audit log for filter dropdowns. Admin only.
|
||||
* tags:
|
||||
* - Audit
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of distinct action strings.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to fetch audit log actions.
|
||||
*/
|
||||
router.get("/audit-logs/actions", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const adminUser = await db
|
||||
.select({ isAdmin: users.isAdmin })
|
||||
.from(users)
|
||||
.where(eq(users.id, authReq.userId))
|
||||
.limit(1);
|
||||
|
||||
if (!adminUser[0]?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const rows = db.$client
|
||||
.prepare("SELECT DISTINCT action FROM audit_logs ORDER BY action ASC")
|
||||
.all() as { action: string }[];
|
||||
|
||||
return res.json({ actions: rows.map((r) => r.action) });
|
||||
} catch (err) {
|
||||
apiLogger.error("Failed to fetch audit log actions", err);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch audit log actions" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -153,7 +154,9 @@ router.post(
|
||||
error: keyInfo.error,
|
||||
});
|
||||
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).",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -186,6 +189,25 @@ router.post(
|
||||
userId,
|
||||
)) as typeof credentialData & { id: number };
|
||||
|
||||
const { ipAddress: ccIp, userAgent: ccUa } = getRequestMeta(req);
|
||||
const { users: usersTableCc } = await import("../db/schema.js");
|
||||
const ccActor = await db
|
||||
.select({ username: usersTableCc.username })
|
||||
.from(usersTableCc)
|
||||
.where(eq(usersTableCc.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: ccActor[0]?.username ?? userId,
|
||||
action: "create_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: String(created.id),
|
||||
resourceName: name,
|
||||
ipAddress: ccIp,
|
||||
userAgent: ccUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
authLogger.success(
|
||||
`SSH credential created: ${name} (${authType}) by user ${userId}`,
|
||||
{
|
||||
@@ -505,7 +527,9 @@ router.put(
|
||||
error: keyInfo.error,
|
||||
});
|
||||
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;
|
||||
@@ -567,6 +591,25 @@ router.put(
|
||||
credentialId: parseInt(id),
|
||||
});
|
||||
|
||||
const { ipAddress: cuIp, userAgent: cuUa } = getRequestMeta(req);
|
||||
const { users: usersTableCu } = await import("../db/schema.js");
|
||||
const cuActor = await db
|
||||
.select({ username: usersTableCu.username })
|
||||
.from(usersTableCu)
|
||||
.where(eq(usersTableCu.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: cuActor[0]?.username ?? userId,
|
||||
action: "update_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: id,
|
||||
resourceName: existing[0].name,
|
||||
ipAddress: cuIp,
|
||||
userAgent: cuUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(formatCredentialOutput(updated[0]));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update credential", err);
|
||||
@@ -697,6 +740,25 @@ router.delete(
|
||||
credentialId: parseInt(id),
|
||||
});
|
||||
|
||||
const { ipAddress: cdIp, userAgent: cdUa } = getRequestMeta(req);
|
||||
const { users: usersTableCd } = await import("../db/schema.js");
|
||||
const cdActor = await db
|
||||
.select({ username: usersTableCd.username })
|
||||
.from(usersTableCd)
|
||||
.where(eq(usersTableCd.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: cdActor[0]?.username ?? userId,
|
||||
action: "delete_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: id,
|
||||
resourceName: credentialToDelete[0].name,
|
||||
ipAddress: cdIp,
|
||||
userAgent: cdUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: "Credential deleted successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete credential", err);
|
||||
@@ -928,7 +990,7 @@ function formatSSHHostOutput(
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
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" });
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { db } from "../db/index.js";
|
||||
import {
|
||||
auditLogs,
|
||||
commandHistory,
|
||||
dashboardPreferences,
|
||||
dismissedAlerts,
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
@@ -77,9 +76,6 @@ export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
|
||||
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
|
||||
await db
|
||||
.delete(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
|
||||
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
|
||||
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
|
||||
|
||||
@@ -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,
|
||||
} 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(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
@@ -100,7 +162,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
try {
|
||||
const ownedHosts = await db
|
||||
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
|
||||
.select({
|
||||
id: hosts.id,
|
||||
statsConfig: hosts.statsConfig,
|
||||
credentialId: hosts.credentialId,
|
||||
proxmoxConfig: hosts.proxmoxConfig,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
|
||||
|
||||
@@ -132,6 +199,12 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates.enableFileManager = updates.enableFileManager;
|
||||
if (typeof updates.enableDocker === "boolean")
|
||||
simpleUpdates.enableDocker = updates.enableDocker;
|
||||
if (typeof updates.enableTmuxMonitor === "boolean")
|
||||
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
|
||||
// Disabling Proxmox is a plain flag flip; enabling is handled per-host
|
||||
// below so each host can default to its own stored credential.
|
||||
if (updates.enableProxmox === false)
|
||||
simpleUpdates.enableProxmox = false;
|
||||
|
||||
if (Object.keys(simpleUpdates).length > 0) {
|
||||
await db
|
||||
@@ -157,6 +230,37 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Enabling Proxmox needs per-host handling: each host defaults its
|
||||
// Proxmox credential to the credential already stored on that host, so
|
||||
// discovery works right away without picking one by hand. Existing
|
||||
// proxmoxConfig values are preserved.
|
||||
if (updates.enableProxmox === true) {
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const existing = host.proxmoxConfig
|
||||
? JSON.parse(host.proxmoxConfig as string)
|
||||
: {};
|
||||
const merged = {
|
||||
defaultCredentialId:
|
||||
existing.defaultCredentialId ?? host.credentialId ?? null,
|
||||
windowsPatterns: existing.windowsPatterns ?? "win, windows",
|
||||
dockerPatterns: existing.dockerPatterns ?? "docker",
|
||||
preferredPrefixes:
|
||||
existing.preferredPrefixes ?? "10., 192.168.",
|
||||
};
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: JSON.stringify(merged),
|
||||
})
|
||||
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
|
||||
} catch {
|
||||
errors.push(`Failed to enable Proxmox for host ${host.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("bulk_update");
|
||||
|
||||
return res.json({
|
||||
@@ -244,13 +348,18 @@ export function registerHostBulkRoutes(
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
!["password", "key", "credential", "none", "opkssh"].includes(
|
||||
hostData.authType,
|
||||
)
|
||||
![
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"tailscale",
|
||||
].includes(hostData.authType)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', or 'opkssh'`,
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', or 'tailscale'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -316,6 +425,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
if (fallback.length > 0) {
|
||||
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 {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
@@ -340,6 +455,8 @@ export function registerHostBulkRoutes(
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableDocker: hostData.enableDocker || false,
|
||||
enableProxmox: hostData.enableProxmox || false,
|
||||
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
|
||||
@@ -362,6 +479,9 @@ export function registerHostBulkRoutes(
|
||||
dockerConfig: hostData.dockerConfig
|
||||
? JSON.stringify(hostData.dockerConfig)
|
||||
: null,
|
||||
proxmoxConfig: hostData.proxmoxConfig
|
||||
? JSON.stringify(hostData.proxmoxConfig)
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig
|
||||
? JSON.stringify(hostData.terminalConfig)
|
||||
: null,
|
||||
@@ -467,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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { and, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { db, DatabaseSaveTrigger } from "../db/index.js";
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
commandHistory,
|
||||
fileManagerPinned,
|
||||
@@ -75,27 +75,32 @@ export function registerHostFolderRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedHosts = await SimpleDBOps.update(
|
||||
hosts,
|
||||
"ssh_data",
|
||||
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
|
||||
{
|
||||
folder: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const oldPrefix = `${oldName} / `;
|
||||
const newPrefix = `${newName} / `;
|
||||
const childLike = `${oldPrefix}%`;
|
||||
|
||||
// folder is a plaintext column, so a SQL expression renames the exact
|
||||
// folder and re-paths every nested child in one statement.
|
||||
const renameExpr = (col: SQLiteColumn) =>
|
||||
sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE ${newPrefix} || substr(${col}, ${oldPrefix.length + 1}) END`;
|
||||
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, oldName), like(col, childLike));
|
||||
|
||||
const updatedHosts = await db
|
||||
.update(hosts)
|
||||
.set({ folder: renameExpr(hosts.folder), updatedAt: now })
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)))
|
||||
.returning();
|
||||
|
||||
const updatedCredentials = await db
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
folder: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.set({ folder: renameExpr(sshCredentials.folder), updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.folder, oldName),
|
||||
folderMatch(sshCredentials.folder),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
@@ -104,12 +109,9 @@ export function registerHostFolderRoutes(
|
||||
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
name: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.set({ name: renameExpr(sshFolders.name), updatedAt: now })
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -306,17 +308,15 @@ export function registerHostFolderRoutes(
|
||||
});
|
||||
|
||||
try {
|
||||
// Match the folder itself and any nested children (e.g. "fgh / sub").
|
||||
const childLike = `${folderName} / %`;
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, childLike));
|
||||
|
||||
const hostsToDelete = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
|
||||
if (hostsToDelete.length === 0) {
|
||||
return res.json({
|
||||
message: "No hosts found in folder",
|
||||
deletedCount: 0,
|
||||
});
|
||||
}
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
@@ -363,14 +363,18 @@ export function registerHostFolderRoutes(
|
||||
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
if (hostIds.length > 0) {
|
||||
await db
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
// Always remove the folder records (and nested children), even when the
|
||||
// folder held no hosts, so empty folders don't reappear on reload.
|
||||
await db
|
||||
.delete(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||
and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)),
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||
|
||||
@@ -82,7 +82,7 @@ export function registerHostInternalRoutes(router: Router): void {
|
||||
),
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
@@ -155,7 +155,7 @@ export function registerHostInternalRoutes(router: Router): void {
|
||||
tunnelConnections: tunnelConnections,
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
|
||||
@@ -101,7 +101,10 @@ export function registerHostNetworkRoutes(
|
||||
|
||||
try {
|
||||
const host = await db
|
||||
.select({ macAddress: hosts.macAddress })
|
||||
.select({
|
||||
macAddress: hosts.macAddress,
|
||||
wolBroadcastAddress: hosts.wolBroadcastAddress,
|
||||
})
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.then((rows) => rows[0]);
|
||||
@@ -116,7 +119,10 @@ export function registerHostNetworkRoutes(
|
||||
.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", {
|
||||
operation: "wake_on_lan",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
renameFolderPath,
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
@@ -22,6 +23,42 @@ describe("isNonEmptyString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameFolderPath", () => {
|
||||
it("renames an exact folder match", () => {
|
||||
expect(renameFolderPath("Production", "Production", "Prod")).toBe("Prod");
|
||||
});
|
||||
|
||||
it("re-paths nested children under the renamed ancestor", () => {
|
||||
expect(renameFolderPath("Production / Web", "Production", "Prod")).toBe(
|
||||
"Prod / Web",
|
||||
);
|
||||
expect(
|
||||
renameFolderPath("Production / Web / app01", "Production", "Prod"),
|
||||
).toBe("Prod / Web / app01");
|
||||
});
|
||||
|
||||
it("renames a nested folder itself and keeps its parent", () => {
|
||||
expect(
|
||||
renameFolderPath("Production / Web", "Production / Web", "Frontend"),
|
||||
).toBe("Frontend");
|
||||
expect(
|
||||
renameFolderPath(
|
||||
"Production / Web / app01",
|
||||
"Production / Web",
|
||||
"Production / Frontend",
|
||||
),
|
||||
).toBe("Production / Frontend / app01");
|
||||
});
|
||||
|
||||
it("returns null for unrelated folders", () => {
|
||||
expect(renameFolderPath("Staging", "Production", "Prod")).toBeNull();
|
||||
expect(renameFolderPath("Production2", "Production", "Prod")).toBeNull();
|
||||
expect(
|
||||
renameFolderPath("ProductionExtra / Web", "Production", "Prod"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPort", () => {
|
||||
it("accepts ports in range", () => {
|
||||
expect(isValidPort(1)).toBe(true);
|
||||
@@ -153,4 +190,22 @@ describe("transformHostResponse", () => {
|
||||
expect(result.vncPort).toBe(5900);
|
||||
expect(result.telnetPort).toBe(23);
|
||||
});
|
||||
|
||||
it("coerces enableProxmox and parses proxmoxConfig", () => {
|
||||
const result = transformHostResponse({
|
||||
enableProxmox: 1,
|
||||
proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}',
|
||||
});
|
||||
expect(result.enableProxmox).toBe(true);
|
||||
expect(result.proxmoxConfig).toEqual({
|
||||
defaultCredentialId: 3,
|
||||
windowsPatterns: "win",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults enableProxmox to false when absent", () => {
|
||||
const result = transformHostResponse({ port: 22 });
|
||||
expect(result.enableProxmox).toBe(false);
|
||||
expect(result.proxmoxConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,26 @@ export function isValidPort(port: unknown): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
export const FOLDER_PATH_SEPARATOR = " / ";
|
||||
|
||||
/**
|
||||
* Re-paths a folder string when its ancestor folder is renamed. Returns the new
|
||||
* path for an exact match or any nested child, or null when the path is unrelated.
|
||||
* Mirrors the SQL CASE expression used in the folder rename route.
|
||||
*/
|
||||
export function renameFolderPath(
|
||||
folderPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): string | null {
|
||||
if (folderPath === oldName) return newName;
|
||||
const prefix = `${oldName}${FOLDER_PATH_SEPARATOR}`;
|
||||
if (folderPath.startsWith(prefix)) {
|
||||
return `${newName}${FOLDER_PATH_SEPARATOR}${folderPath.slice(prefix.length)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -79,6 +99,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableDocker?: unknown;
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
@@ -91,6 +113,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
quickActions?: unknown;
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
proxmoxConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -214,8 +237,10 @@ export function transformHostResponse(
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableDocker: !!host.enableDocker,
|
||||
enableProxmox: !!host.enableProxmox,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
@@ -264,7 +289,11 @@ export function transformHostResponse(
|
||||
dockerConfig: host.dockerConfig
|
||||
? JSON.parse(host.dockerConfig as string)
|
||||
: undefined,
|
||||
proxmoxConfig: host.proxmoxConfig
|
||||
? JSON.parse(host.proxmoxConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useWarpgate: !!host.useWarpgate,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: [],
|
||||
|
||||
+167
-130
@@ -25,6 +25,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
||||
import { pickResolvedUsername } from "../../ssh/credential-username.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
@@ -39,6 +40,7 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
||||
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -142,6 +144,7 @@ router.post(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
key,
|
||||
keyPassword,
|
||||
@@ -151,7 +154,10 @@ router.post(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -163,6 +169,7 @@ router.post(
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -179,6 +186,7 @@ router.post(
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
@@ -238,6 +246,7 @@ router.post(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
@@ -251,7 +260,10 @@ router.post(
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -268,6 +280,11 @@ router.post(
|
||||
? dockerConfig
|
||||
: JSON.stringify(dockerConfig)
|
||||
: null,
|
||||
proxmoxConfig: proxmoxConfig
|
||||
? typeof proxmoxConfig === "string"
|
||||
? proxmoxConfig
|
||||
: JSON.stringify(proxmoxConfig)
|
||||
: null,
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
@@ -289,6 +306,7 @@ router.post(
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -399,6 +417,25 @@ router.post(
|
||||
name,
|
||||
});
|
||||
|
||||
const { ipAddress: chIp, userAgent: chUa } = getRequestMeta(req);
|
||||
const { users: usersTable } = await import("../db/schema.js");
|
||||
const chActor = await db
|
||||
.select({ username: usersTable.username })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: chActor[0]?.username ?? userId,
|
||||
action: "create_host",
|
||||
resourceType: "host",
|
||||
resourceId: String(createdHost.id),
|
||||
resourceName: String(name ?? ip),
|
||||
ipAddress: chIp,
|
||||
userAgent: chUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(
|
||||
createdHost.id as number,
|
||||
@@ -571,6 +608,8 @@ router.post(
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
@@ -680,6 +719,7 @@ router.put(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
key,
|
||||
keyPassword,
|
||||
@@ -689,7 +729,10 @@ router.put(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -701,6 +744,7 @@ router.put(
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -717,6 +761,7 @@ router.put(
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
@@ -773,6 +818,7 @@ router.put(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
@@ -786,7 +832,10 @@ router.put(
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
scpLegacy: scpLegacy ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -803,6 +852,11 @@ router.put(
|
||||
? dockerConfig
|
||||
: JSON.stringify(dockerConfig)
|
||||
: null,
|
||||
proxmoxConfig: proxmoxConfig
|
||||
? typeof proxmoxConfig === "string"
|
||||
? proxmoxConfig
|
||||
: JSON.stringify(proxmoxConfig)
|
||||
: null,
|
||||
terminalConfig: terminalConfig
|
||||
? typeof terminalConfig === "string"
|
||||
? terminalConfig
|
||||
@@ -824,6 +878,7 @@ router.put(
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -909,10 +964,9 @@ router.put(
|
||||
sshDataObj.keyType = null;
|
||||
}
|
||||
|
||||
if (rdpPassword !== undefined) sshDataObj.rdpPassword = rdpPassword || null;
|
||||
if (vncPassword !== undefined) sshDataObj.vncPassword = vncPassword || null;
|
||||
if (telnetPassword !== undefined)
|
||||
sshDataObj.telnetPassword = telnetPassword || null;
|
||||
if (rdpPassword) sshDataObj.rdpPassword = rdpPassword;
|
||||
if (vncPassword) sshDataObj.vncPassword = vncPassword;
|
||||
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
|
||||
|
||||
try {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
@@ -945,6 +999,8 @@ router.put(
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
@@ -982,11 +1038,26 @@ router.put(
|
||||
});
|
||||
}
|
||||
|
||||
if (sshDataObj.credentialId !== undefined) {
|
||||
if (
|
||||
hostRecord[0].credentialId !== null &&
|
||||
sshDataObj.credentialId === null
|
||||
) {
|
||||
{
|
||||
const newCredId =
|
||||
sshDataObj.credentialId !== undefined
|
||||
? 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
|
||||
.delete(hostAccess)
|
||||
.where(eq(hostAccess.hostId, Number(hostId)));
|
||||
@@ -1030,6 +1101,25 @@ router.put(
|
||||
hostId: parseInt(hostId),
|
||||
});
|
||||
|
||||
const { ipAddress: uhIp, userAgent: uhUa } = getRequestMeta(req);
|
||||
const { users: usersTableUpd } = await import("../db/schema.js");
|
||||
const uhActor = await db
|
||||
.select({ username: usersTableUpd.username })
|
||||
.from(usersTableUpd)
|
||||
.where(eq(usersTableUpd.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: uhActor[0]?.username ?? userId,
|
||||
action: "update_host",
|
||||
resourceType: "host",
|
||||
resourceId: hostId,
|
||||
resourceName: String(name ?? ip),
|
||||
ipAddress: uhIp,
|
||||
userAgent: uhUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(resolvedHost);
|
||||
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
|
||||
} catch (err) {
|
||||
@@ -1107,6 +1197,7 @@ router.get(
|
||||
tunnelConnections: hosts.tunnelConnections,
|
||||
jumpHosts: hosts.jumpHosts,
|
||||
enableFileManager: hosts.enableFileManager,
|
||||
scpLegacy: hosts.scpLegacy,
|
||||
defaultPath: hosts.defaultPath,
|
||||
autostartPassword: hosts.autostartPassword,
|
||||
autostartKey: hosts.autostartKey,
|
||||
@@ -1122,6 +1213,8 @@ router.get(
|
||||
quickActions: hosts.quickActions,
|
||||
notes: hosts.notes,
|
||||
enableDocker: hosts.enableDocker,
|
||||
enableProxmox: hosts.enableProxmox,
|
||||
enableTmuxMonitor: hosts.enableTmuxMonitor,
|
||||
showTerminalInSidebar: hosts.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: hosts.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: hosts.showTunnelInSidebar,
|
||||
@@ -1139,7 +1232,9 @@ router.get(
|
||||
ignoreCert: hosts.ignoreCert,
|
||||
guacamoleConfig: hosts.guacamoleConfig,
|
||||
macAddress: hosts.macAddress,
|
||||
wolBroadcastAddress: hosts.wolBroadcastAddress,
|
||||
dockerConfig: hosts.dockerConfig,
|
||||
proxmoxConfig: hosts.proxmoxConfig,
|
||||
enableSsh: hosts.enableSsh,
|
||||
enableRdp: hosts.enableRdp,
|
||||
enableVnc: hosts.enableVnc,
|
||||
@@ -1148,11 +1243,13 @@ router.get(
|
||||
rdpPort: hosts.rdpPort,
|
||||
vncPort: hosts.vncPort,
|
||||
telnetPort: hosts.telnetPort,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
rdpUser: hosts.rdpUser,
|
||||
rdpPassword: hosts.rdpPassword,
|
||||
rdpDomain: hosts.rdpDomain,
|
||||
rdpSecurity: hosts.rdpSecurity,
|
||||
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
telnetUser: hosts.telnetUser,
|
||||
@@ -1380,7 +1477,19 @@ router.get(
|
||||
|
||||
const host = data[0];
|
||||
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) {
|
||||
return res.status(404).json({ error: "No password set" });
|
||||
@@ -1509,8 +1618,11 @@ router.get(
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!resolvedHost.enableTerminal,
|
||||
enableTunnel: !!resolvedHost.enableTunnel,
|
||||
enableFileManager: !!resolvedHost.enableFileManager,
|
||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||
scpLegacy: !!resolvedHost.scpLegacy,
|
||||
enableDocker: !!resolvedHost.enableDocker,
|
||||
enableProxmox: !!resolvedHost.enableProxmox,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -1533,6 +1645,9 @@ router.get(
|
||||
dockerConfig: resolvedHost.dockerConfig
|
||||
? JSON.parse(resolvedHost.dockerConfig as string)
|
||||
: null,
|
||||
proxmoxConfig: resolvedHost.proxmoxConfig
|
||||
? JSON.parse(resolvedHost.proxmoxConfig as string)
|
||||
: null,
|
||||
terminalConfig: resolvedHost.terminalConfig
|
||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||
: null,
|
||||
@@ -1571,7 +1686,7 @@ router.get(
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/db/hosts/export:
|
||||
* /host/db/hosts/export:
|
||||
* get:
|
||||
* summary: Export all SSH hosts
|
||||
* description: Exports all SSH hosts for the current user with decrypted credentials.
|
||||
@@ -1652,8 +1767,10 @@ router.get(
|
||||
!!resolvedHost.overrideCredentialUsername,
|
||||
enableTerminal: !!resolvedHost.enableTerminal,
|
||||
enableTunnel: !!resolvedHost.enableTunnel,
|
||||
enableFileManager: !!resolvedHost.enableFileManager,
|
||||
enableFileManager: resolvedHost.enableFileManager !== false,
|
||||
enableDocker: !!resolvedHost.enableDocker,
|
||||
enableProxmox: !!resolvedHost.enableProxmox,
|
||||
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
|
||||
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
|
||||
@@ -1676,6 +1793,9 @@ router.get(
|
||||
dockerConfig: resolvedHost.dockerConfig
|
||||
? JSON.parse(resolvedHost.dockerConfig as string)
|
||||
: null,
|
||||
proxmoxConfig: resolvedHost.proxmoxConfig
|
||||
? JSON.parse(resolvedHost.proxmoxConfig as string)
|
||||
: null,
|
||||
terminalConfig: resolvedHost.terminalConfig
|
||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||
: null,
|
||||
@@ -1713,7 +1833,7 @@ router.get(
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/db/host/{id}:
|
||||
* /host/db/host/{id}:
|
||||
* delete:
|
||||
* summary: Delete SSH host
|
||||
* description: Deletes an SSH host by its ID.
|
||||
@@ -1824,6 +1944,25 @@ router.delete(
|
||||
hostId: parseInt(hostId),
|
||||
});
|
||||
|
||||
const { ipAddress: dhIp, userAgent: dhUa } = getRequestMeta(req);
|
||||
const { users: usersTableDel } = await import("../db/schema.js");
|
||||
const dhActor = await db
|
||||
.select({ username: usersTableDel.username })
|
||||
.from(usersTableDel)
|
||||
.where(eq(usersTableDel.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: dhActor[0]?.username ?? userId,
|
||||
action: "delete_host",
|
||||
resourceType: "host",
|
||||
resourceId: hostId,
|
||||
resourceName: hostToDelete[0].name ?? hostToDelete[0].ip,
|
||||
ipAddress: dhIp,
|
||||
userAgent: dhUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
await axios.post(
|
||||
@@ -2002,11 +2141,13 @@ async function resolveHostCredentials(
|
||||
keyType: sharedCred.keyType,
|
||||
};
|
||||
|
||||
if (
|
||||
!host.overrideCredentialUsername &&
|
||||
isNonEmptyString(sharedCred.username)
|
||||
) {
|
||||
resolvedHost.username = sharedCred.username;
|
||||
const resolvedUsername = pickResolvedUsername(
|
||||
host.username,
|
||||
sharedCred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
if (resolvedUsername !== undefined) {
|
||||
resolvedHost.username = resolvedUsername;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
@@ -2051,11 +2192,13 @@ async function resolveHostCredentials(
|
||||
keyType: credential.keyType,
|
||||
};
|
||||
|
||||
if (
|
||||
!host.overrideCredentialUsername &&
|
||||
isNonEmptyString(credential.username)
|
||||
) {
|
||||
resolvedHost.username = credential.username;
|
||||
const resolvedUsername = pickResolvedUsername(
|
||||
host.username,
|
||||
credential.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
if (resolvedUsername !== undefined) {
|
||||
resolvedHost.username = resolvedUsername;
|
||||
}
|
||||
|
||||
return resolvedHost;
|
||||
@@ -2078,112 +2221,6 @@ registerHostFolderRoutes(router, {
|
||||
|
||||
registerHostBulkRoutes(router, authenticateJWT);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/{folderName}/hosts:
|
||||
* delete:
|
||||
* summary: Delete all hosts in a folder
|
||||
* description: Deletes all hosts within a specific folder.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: folderName
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: All hosts deleted successfully.
|
||||
* 400:
|
||||
* description: Invalid folder name.
|
||||
* 500:
|
||||
* description: Failed to delete hosts.
|
||||
*/
|
||||
router.delete(
|
||||
"/folders/:folderName/hosts",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folderName = decodeURIComponent(
|
||||
Array.isArray(req.params.folderName)
|
||||
? req.params.folderName[0]
|
||||
: req.params.folderName,
|
||||
);
|
||||
|
||||
if (!folderName) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const hostsToDelete = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
|
||||
if (hostsToDelete.length === 0) {
|
||||
return res.json({ deletedCount: 0 });
|
||||
}
|
||||
|
||||
const hostIds = hostsToDelete.map((h) => h.id);
|
||||
|
||||
for (const hostId of hostIds) {
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.hostId, hostId));
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.hostId, hostId));
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.hostId, hostId));
|
||||
await db
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
eq(transferRecent.sourceHostId, hostId),
|
||||
eq(transferRecent.destHostId, hostId),
|
||||
),
|
||||
);
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(eq(commandHistory.hostId, hostId));
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.hostId, hostId));
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.hostId, hostId));
|
||||
await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId));
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.hostId, hostId));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
|
||||
databaseLogger.success("All hosts in folder deleted", {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
|
||||
res.json({ deletedCount: hostsToDelete.length });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
registerHostAutostartRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { Router } from "express";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { ssoProviders, users, roles, userRoles } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { isOIDCUserAllowed, loadProviderConfig } from "./user-oidc-utils.js";
|
||||
import ldap from "ldapjs";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLDAPAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login
|
||||
* description: Authenticates a user against an LDAP server.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerId:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 403:
|
||||
* description: User not allowed.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.post("/ldap/login", async (req, res) => {
|
||||
const { providerId, username, password, rememberMe } = req.body as {
|
||||
providerId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "providerId, username, and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, providerId))
|
||||
.limit(1);
|
||||
if (rows.length === 0 || rows[0].type !== "ldap" || !rows[0].enabled) {
|
||||
return res.status(404).json({ error: "LDAP provider not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to load LDAP provider", err);
|
||||
return res.status(500).json({ error: "Failed to load LDAP provider" });
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
return res.status(404).json({ error: "LDAP provider not found" });
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
return res.status(500).json({ error: "LDAP provider is misconfigured" });
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const attrList = [
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
];
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
attrList,
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
|
||||
const getAttr = (key: string): string => {
|
||||
const attr = userEntry.attributes.find((a) => a.type === key);
|
||||
return attr
|
||||
? Array.isArray(attr.values)
|
||||
? attr.values[0]
|
||||
: String(attr.values)
|
||||
: "";
|
||||
};
|
||||
|
||||
const ldapIdentifier =
|
||||
getAttr(config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
getAttr(config.displayNameAttribute || "cn") || username;
|
||||
const email = getAttr("mail") || getAttr("email") || "";
|
||||
|
||||
if (config.allowedUsers) {
|
||||
if (
|
||||
!isOIDCUserAllowed(
|
||||
config.allowedUsers,
|
||||
ldapIdentifier,
|
||||
email || undefined,
|
||||
)
|
||||
) {
|
||||
authLogger.warn("LDAP user not in allowed list", {
|
||||
operation: "ldap_user_not_allowed",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(403).json({ error: "User not allowed" });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-bind with user's own credentials to verify password
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
return res.status(401).json({ error: "Invalid username or password" });
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
// Admin group check
|
||||
let isAdmin = false;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
try {
|
||||
const groupFilter = `(member=${ldapEscapeFilter(userDN)})`;
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
groupFilter,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some((g) => {
|
||||
const cn = g.attributes.find((a) => a.type === "cn");
|
||||
const cnVal = cn
|
||||
? Array.isArray(cn.values)
|
||||
? cn.values[0]
|
||||
: String(cn.values)
|
||||
: "";
|
||||
return (
|
||||
cnVal === config.adminGroup ||
|
||||
g.dn.toString() === config.adminGroup
|
||||
);
|
||||
});
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ldapUnbind(serviceClient);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const oidcIdentifier = `ldap:${providerId}:${ldapIdentifier}`;
|
||||
|
||||
let existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.oidcIdentifier, oidcIdentifier));
|
||||
|
||||
let userId: string;
|
||||
if (existingUsers.length === 0) {
|
||||
let autoProvision = false;
|
||||
try {
|
||||
const r = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
if (r) autoProvision = r.value === "true";
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!autoProvision)
|
||||
autoProvision =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
const countRow = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get() as { count?: number };
|
||||
const isFirst = (countRow?.count || 0) === 0;
|
||||
|
||||
if (!isFirst && !autoProvision) {
|
||||
return res.status(403).json({ error: "Registration is disabled" });
|
||||
}
|
||||
|
||||
userId = nanoid();
|
||||
const isFirstFinal = db.$client.transaction(() => {
|
||||
const c =
|
||||
(
|
||||
db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get() as { count?: number }
|
||||
)?.count || 0;
|
||||
const first = c === 0;
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
displayName,
|
||||
"",
|
||||
first || isAdmin ? 1 : 0,
|
||||
oidcIdentifier,
|
||||
providerId,
|
||||
);
|
||||
return first;
|
||||
})();
|
||||
|
||||
try {
|
||||
const defaultRoleName = isFirstFinal || isAdmin ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
if (defaultRole.length > 0)
|
||||
await db
|
||||
.insert(userRoles)
|
||||
.values({ userId, roleId: defaultRole[0].id, grantedBy: userId });
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(userId, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
authLogger.error(
|
||||
"Failed to setup LDAP user encryption",
|
||||
encryptionError,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to setup user security" });
|
||||
}
|
||||
|
||||
existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
} else {
|
||||
userId = existingUsers[0].id;
|
||||
|
||||
// Sync admin status from group membership
|
||||
if (config.adminGroup && !!existingUsers[0].isAdmin !== isAdmin) {
|
||||
await db.update(users).set({ isAdmin }).where(eq(users.id, userId));
|
||||
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
|
||||
const isDualAuth =
|
||||
existingUsers[0].passwordHash &&
|
||||
existingUsers[0].passwordHash.trim() !== "";
|
||||
if (!isDualAuth && existingUsers[0].username !== displayName) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username: displayName })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
}
|
||||
|
||||
const userRecord = existingUsers[0];
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
rememberMe: rememberMe || false,
|
||||
});
|
||||
|
||||
authLogger.success("LDAP login successful", {
|
||||
operation: "ldap_login_complete",
|
||||
userId: userRecord.id,
|
||||
username: userRecord.username,
|
||||
});
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.json({ success: true, message: "Login successful" });
|
||||
} catch (err) {
|
||||
ldapUnbind(serviceClient);
|
||||
authLogger.error("LDAP login failed", err);
|
||||
return res.status(500).json({ error: "LDAP authentication failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -23,12 +23,39 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
* 200:
|
||||
* 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
|
||||
// tabs still restore to the correct (renamed) tab type.
|
||||
const LEGACY_TAB_TYPE_MAP: Record<string, string> = {
|
||||
stats: "host-metrics",
|
||||
};
|
||||
|
||||
function normalizeTabType(tabType: string): string {
|
||||
return LEGACY_TAB_TYPE_MAP[tabType] ?? tabType;
|
||||
}
|
||||
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - TAB_TTL_MS).toISOString();
|
||||
const cutoff = new Date(Date.now() - getTabTtlMs()).toISOString();
|
||||
const tabs = db
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
@@ -40,7 +67,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
)
|
||||
.orderBy(userOpenTabs.tabOrder)
|
||||
.all();
|
||||
return res.json(tabs);
|
||||
return res.json(
|
||||
tabs.map((tab) => ({ ...tab, tabType: normalizeTabType(tab.tabType) })),
|
||||
);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get open tabs", e, {
|
||||
operation: "get_open_tabs",
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import express from "express";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { logger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "../../ssh/host-key-verifier.js";
|
||||
|
||||
const router = express.Router();
|
||||
const proxmoxLogger = logger;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
|
||||
// but we validate defensively before using in a shell command.
|
||||
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
||||
|
||||
function isSafeNodeName(name: string): boolean {
|
||||
return SAFE_NODE_RE.test(name);
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
client: SSHClient,
|
||||
command: string,
|
||||
timeoutMs = 8000,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
return reject(err);
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("close", (code: number) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0)
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
else resolve(stdout);
|
||||
});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Parse all IPs from LXC net config, then return the one matching the preferred prefix.
|
||||
function parseLxcIp(
|
||||
config: Record<string, unknown>,
|
||||
preferredPrefixes: string[] = [],
|
||||
): string | null {
|
||||
const ips: string[] = [];
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (/^net\d+$/.test(key) && typeof value === "string") {
|
||||
const m = value.match(/ip=(\d{1,3}(?:\.\d{1,3}){3})/);
|
||||
if (m) ips.push(m[1]);
|
||||
}
|
||||
}
|
||||
if (!ips.length) return null;
|
||||
for (const prefix of preferredPrefixes) {
|
||||
const match = ips.find((ip) => ip.startsWith(prefix));
|
||||
if (match) return match;
|
||||
}
|
||||
return ips[0];
|
||||
}
|
||||
|
||||
function matchesAny(name: string, patterns: string[]): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return patterns.some((p) => lower.includes(p.toLowerCase()));
|
||||
}
|
||||
|
||||
function parseProxmoxConfig(raw: unknown): {
|
||||
windowsPatterns: string[];
|
||||
dockerPatterns: string[];
|
||||
preferredPrefixes: string[];
|
||||
} {
|
||||
const split = (s: string) =>
|
||||
s
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {
|
||||
windowsPatterns: ["win", "windows"],
|
||||
dockerPatterns: ["docker"],
|
||||
preferredPrefixes: [],
|
||||
};
|
||||
}
|
||||
const cfg = raw as Record<string, unknown>;
|
||||
return {
|
||||
windowsPatterns: split(
|
||||
typeof cfg.windowsPatterns === "string"
|
||||
? cfg.windowsPatterns
|
||||
: "win,windows",
|
||||
),
|
||||
dockerPatterns: split(
|
||||
typeof cfg.dockerPatterns === "string" ? cfg.dockerPatterns : "docker",
|
||||
),
|
||||
preferredPrefixes: split(
|
||||
typeof cfg.preferredPrefixes === "string" ? cfg.preferredPrefixes : "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /proxmox/discover:
|
||||
* post:
|
||||
* summary: Discover Proxmox guests on a node
|
||||
* description: >
|
||||
* Connects to an existing SSH host (a Proxmox node) using its stored
|
||||
* credentials, runs pvesh to enumerate all guests (VMs and LXC
|
||||
* containers) in the cluster, and returns them ready to be imported as
|
||||
* Termix hosts. No separate Proxmox API token is required.
|
||||
* tags: [Proxmox]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [hostId]
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: number
|
||||
* description: ID of the SSH host that is a Proxmox node.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Discovered guests.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* guests:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* vmid:
|
||||
* type: number
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [qemu, lxc]
|
||||
* node:
|
||||
* type: string
|
||||
* status:
|
||||
* type: string
|
||||
* ip:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* connectionType:
|
||||
* type: string
|
||||
* enum: [ssh, rdp]
|
||||
* enableDocker:
|
||||
* type: boolean
|
||||
* credentialId:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* defaultCredentialId:
|
||||
* type: number
|
||||
* nullable: true
|
||||
* 400:
|
||||
* description: Missing or invalid hostId.
|
||||
* 401:
|
||||
* description: Authentication required or session expired.
|
||||
* 403:
|
||||
* description: Access denied to the host.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
* 422:
|
||||
* description: Host is not a Proxmox node or is unreachable.
|
||||
* 500:
|
||||
* description: Discovery failed.
|
||||
*/
|
||||
router.post(
|
||||
"/discover",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req, res) => {
|
||||
const { hostId } = req.body as { hostId?: unknown };
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
|
||||
const parsedHostId = Number(hostId);
|
||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired — please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Load host from DB
|
||||
// -----------------------------------------------------------------------
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, parsedHostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!hostResults.length) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const host = hostResults[0] as unknown as SSHHost;
|
||||
|
||||
// Read discovery settings from the host's proxmoxConfig
|
||||
const proxmoxCfgRaw = host.proxmoxConfig
|
||||
? typeof host.proxmoxConfig === "string"
|
||||
? JSON.parse(host.proxmoxConfig)
|
||||
: host.proxmoxConfig
|
||||
: null;
|
||||
const { windowsPatterns, dockerPatterns, preferredPrefixes } =
|
||||
parseProxmoxConfig(proxmoxCfgRaw);
|
||||
const proxmoxDefaultCredentialId =
|
||||
proxmoxCfgRaw && typeof proxmoxCfgRaw === "object"
|
||||
? (((proxmoxCfgRaw as Record<string, unknown>).defaultCredentialId as
|
||||
| number
|
||||
| null) ?? null)
|
||||
: null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Permission check
|
||||
// -----------------------------------------------------------------------
|
||||
if (host.userId !== userId) {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const pm = PermissionManager.getInstance();
|
||||
const access = await pm.canAccessHost(userId, parsedHostId, "execute");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Credential resolution (mirrors docker.ts pattern)
|
||||
// -----------------------------------------------------------------------
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: host.password,
|
||||
sshKey: host.key,
|
||||
keyPassword: host.keyPassword,
|
||||
authType: host.authType,
|
||||
};
|
||||
|
||||
const hostCredentialId = host.credentialId ?? null;
|
||||
|
||||
if (host.credentialId) {
|
||||
if (userId !== host.userId) {
|
||||
try {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCred =
|
||||
await SharedCredentialManager.getInstance().getSharedCredentialForUser(
|
||||
host.id,
|
||||
userId,
|
||||
);
|
||||
if (sharedCred) {
|
||||
resolvedCredentials = {
|
||||
password: sharedCred.password,
|
||||
sshKey: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
authType: sharedCred.authType,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
proxmoxLogger.error("Failed to resolve shared credential", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const creds = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
if (creds.length > 0) {
|
||||
const c = creds[0];
|
||||
resolvedCredentials = {
|
||||
password: c.password as string | undefined,
|
||||
sshKey: (c.key || c.privateKey) as string | undefined,
|
||||
keyPassword: c.keyPassword as string | undefined,
|
||||
authType: c.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Build SSH config
|
||||
// -----------------------------------------------------------------------
|
||||
const sshConfig: Record<string, unknown> = {
|
||||
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
tryKeyboard: false,
|
||||
readyTimeout: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
parsedHostId,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
const authType = resolvedCredentials.authType;
|
||||
if (authType === "key" && resolvedCredentials.sshKey) {
|
||||
sshConfig.privateKey = resolvedCredentials.sshKey;
|
||||
if (resolvedCredentials.keyPassword)
|
||||
sshConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
} else if (resolvedCredentials.password) {
|
||||
sshConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Connect → discover → disconnect
|
||||
// -----------------------------------------------------------------------
|
||||
const client = new SSHClient();
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", resolve);
|
||||
client.on("error", reject);
|
||||
client.connect(sshConfig as import("ssh2").ConnectConfig);
|
||||
});
|
||||
|
||||
proxmoxLogger.info("Proxmox discovery SSH connection established", {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Verify pvesh is present — fail fast with a clear error
|
||||
const pveshCheck = await execCommand(
|
||||
client,
|
||||
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
|
||||
);
|
||||
if (pveshCheck.trim() !== "ok") {
|
||||
return res
|
||||
.status(422)
|
||||
.json({ error: "pvesh not found — is this a Proxmox node?" });
|
||||
}
|
||||
|
||||
// Fetch all cluster resources in one call
|
||||
const resourcesJson = await execCommand(
|
||||
client,
|
||||
"pvesh get /cluster/resources --output-format json 2>/dev/null",
|
||||
);
|
||||
|
||||
let resources: Array<Record<string, unknown>>;
|
||||
try {
|
||||
resources = JSON.parse(resourcesJson);
|
||||
} catch {
|
||||
return res.status(502).json({
|
||||
error: "Failed to parse pvesh output — unexpected response",
|
||||
});
|
||||
}
|
||||
|
||||
// Collect basic guest metadata first (no IP yet)
|
||||
type GuestBase = {
|
||||
name: string;
|
||||
vmid: number;
|
||||
type: "qemu" | "lxc";
|
||||
node: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const guestBases: GuestBase[] = [];
|
||||
for (const r of resources) {
|
||||
const type = r.type as string;
|
||||
if (type !== "qemu" && type !== "lxc") continue;
|
||||
if (r.template) continue;
|
||||
const node = r.node as string;
|
||||
if (!isSafeNodeName(node)) {
|
||||
proxmoxLogger.warn("Skipping guest with unsafe node name", {
|
||||
operation: "proxmox_discover",
|
||||
node,
|
||||
vmid: r.vmid,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
guestBases.push({
|
||||
name: (r.name as string) || String(r.vmid),
|
||||
vmid: Number(r.vmid),
|
||||
type: type as "qemu" | "lxc",
|
||||
node,
|
||||
status: (r.status as string) || "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve IPs for all guests in parallel — keeps total time near
|
||||
// max(single_resolution) instead of sum(all_resolutions).
|
||||
async function resolveIp(g: GuestBase): Promise<string | null> {
|
||||
if (g.type === "lxc") {
|
||||
try {
|
||||
const cfgJson = await execCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
||||
8000,
|
||||
);
|
||||
return parseLxcIp(JSON.parse(cfgJson), preferredPrefixes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (g.type === "qemu" && g.status === "running") {
|
||||
try {
|
||||
const ifJson = await execCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
|
||||
5000,
|
||||
);
|
||||
const data = JSON.parse(ifJson);
|
||||
const ifaces: Array<Record<string, unknown>> = Array.isArray(
|
||||
data?.result,
|
||||
)
|
||||
? data.result
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
const allIps: string[] = [];
|
||||
for (const iface of ifaces) {
|
||||
if (iface.name === "lo") continue;
|
||||
const addrs =
|
||||
(iface["ip-addresses"] as Array<Record<string, string>>) ??
|
||||
[];
|
||||
for (const a of addrs) {
|
||||
if (
|
||||
a["ip-address-type"] === "ipv4" &&
|
||||
!a["ip-address"].startsWith("127.")
|
||||
) {
|
||||
allIps.push(a["ip-address"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allIps.length) {
|
||||
for (const prefix of preferredPrefixes) {
|
||||
const match = allIps.find((ip) => ip.startsWith(prefix));
|
||||
if (match) return match;
|
||||
}
|
||||
return allIps[0];
|
||||
}
|
||||
} catch {
|
||||
// Guest agent absent or timed out
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve IPs with bounded concurrency: each lookup opens an SSH exec
|
||||
// channel, and OpenSSH's default MaxSessions is 10. Capping the number
|
||||
// of in-flight channels keeps discovery reliable on large clusters.
|
||||
const CONCURRENCY = 6;
|
||||
const ips: (string | null)[] = new Array(guestBases.length).fill(null);
|
||||
let cursor = 0;
|
||||
async function ipWorker() {
|
||||
while (cursor < guestBases.length) {
|
||||
const i = cursor++;
|
||||
ips[i] = await resolveIp(guestBases[i]);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, guestBases.length) }, () =>
|
||||
ipWorker(),
|
||||
),
|
||||
);
|
||||
const guests = guestBases.map((g, i) => ({
|
||||
...g,
|
||||
ip: ips[i],
|
||||
connectionType: matchesAny(g.name, windowsPatterns) ? "rdp" : "ssh",
|
||||
enableDocker: matchesAny(g.name, dockerPatterns),
|
||||
}));
|
||||
|
||||
proxmoxLogger.info("Proxmox discovery completed", {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
guestCount: guests.length,
|
||||
});
|
||||
|
||||
// Return guests with connection type + docker flag, plus credential info
|
||||
// so the frontend can pre-populate auth for imported hosts.
|
||||
return res.json({
|
||||
guests,
|
||||
credentialId: hostCredentialId,
|
||||
defaultCredentialId: proxmoxDefaultCredentialId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
proxmoxLogger.error("Proxmox discovery failed", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (
|
||||
message.includes("pvesh not found") ||
|
||||
message.includes("Authentication failed") ||
|
||||
message.includes("connect ECONNREFUSED") ||
|
||||
message.includes("connect ETIMEDOUT")
|
||||
) {
|
||||
return res.status(422).json({ error: `Discovery failed: ${message}` });
|
||||
}
|
||||
return res.status(500).json({ error: `Discovery failed: ${message}` });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -129,7 +129,12 @@ router.post(
|
||||
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({
|
||||
error:
|
||||
"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)
|
||||
.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 } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
activeCredentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
activeCredentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
@@ -252,18 +261,22 @@ router.post(
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (host[0].credentialId) {
|
||||
const activeCredentialId =
|
||||
host[0].credentialId ??
|
||||
host[0].rdpCredentialId ??
|
||||
host[0].vncCredentialId;
|
||||
if (activeCredentialId) {
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
activeCredentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
activeCredentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
@@ -487,6 +500,12 @@ router.get(
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||
|
||||
const sharedHosts = await db
|
||||
.select({
|
||||
id: hosts.id,
|
||||
@@ -506,7 +525,15 @@ router.get(
|
||||
.innerJoin(users, eq(hosts.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.userId, userId),
|
||||
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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import path from "path";
|
||||
|
||||
// Stub db, logger, fs, and AuthManager before importing the route module
|
||||
const mockSelect = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
|
||||
vi.mock("../db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
delete: mockDelete,
|
||||
insert: mockInsert,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockReadFile = vi.fn();
|
||||
const mockStat = vi.fn();
|
||||
const mockUnlink = vi.fn();
|
||||
const mockExistsSync = vi.fn();
|
||||
|
||||
vi.mock("fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("fs")>();
|
||||
return {
|
||||
...actual,
|
||||
promises: { readFile: mockReadFile, unlink: mockUnlink },
|
||||
existsSync: mockExistsSync,
|
||||
statSync: mockStat,
|
||||
};
|
||||
});
|
||||
|
||||
// Build a chainable drizzle-like query stub
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"leftJoin",
|
||||
"where",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"set",
|
||||
"values",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe("session-log-routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.DATA_DIR = "/data";
|
||||
});
|
||||
|
||||
describe("GET / - list logs", () => {
|
||||
it("returns logs for the authenticated user with file size", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
hostId: 10,
|
||||
userId: "u1",
|
||||
startedAt: "2026-01-01T00:00:00Z",
|
||||
endedAt: "2026-01-01T00:05:00Z",
|
||||
duration: 300,
|
||||
recordingPath: "/data/session_logs/u1/abc.log",
|
||||
hostName: "my-server",
|
||||
hostIp: "10.0.0.1",
|
||||
},
|
||||
];
|
||||
const chain = makeChain(rows);
|
||||
mockSelect.mockReturnValue(chain);
|
||||
mockStat.mockReturnValue({ size: 4096 });
|
||||
|
||||
// Directly call the route handler extracted from the module
|
||||
const { default: router } = await import("./session-log-routes.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path traversal guard", () => {
|
||||
it("rejects paths outside the allowed session_logs directory", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const malicious = path.resolve("/data/session_logs/../../etc/passwd");
|
||||
expect(malicious.startsWith(allowedBase)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a legitimate session log path", () => {
|
||||
const allowedBase = path.resolve("/data", "session_logs");
|
||||
const valid = path.resolve("/data/session_logs/user1/abc.log");
|
||||
expect(valid.startsWith(allowedBase)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatters (pure logic)", () => {
|
||||
it("stat returns size when file exists", () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockStat.mockReturnValue({ size: 1234 });
|
||||
const exists = mockExistsSync("/some/file.log");
|
||||
const { size } = mockStat("/some/file.log");
|
||||
expect(exists).toBe(true);
|
||||
expect(size).toBe(1234);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { eq, and, desc, lt } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { sessionRecordings, hosts } from "../db/schema.js";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
||||
|
||||
// Delete session log files and DB rows older than this many days
|
||||
const LOG_RETENTION_DAYS = 30;
|
||||
|
||||
async function pruneOldLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(
|
||||
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const old = await db
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(lt(sessionRecordings.startedAt, cutoff));
|
||||
|
||||
for (const row of old) {
|
||||
if (row.recordingPath) {
|
||||
const resolved = path.resolve(row.recordingPath);
|
||||
const allowed = path.resolve(DATA_DIR, "session_logs");
|
||||
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
|
||||
await fs.promises.unlink(resolved).catch(() => {});
|
||||
}
|
||||
}
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.id, row.id));
|
||||
}
|
||||
|
||||
if (old.length > 0) {
|
||||
apiLogger.info(`Pruned ${old.length} old session log(s)`, {
|
||||
operation: "session_log_prune",
|
||||
count: old.length,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
apiLogger.warn("Failed to prune old session logs", {
|
||||
operation: "session_log_prune_error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run prune once at startup, then every 24 hours
|
||||
pruneOldLogs();
|
||||
setInterval(pruneOldLogs, 24 * 60 * 60 * 1000);
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session_logs:
|
||||
* get:
|
||||
* summary: List session logs
|
||||
* description: Returns all terminal session recordings for the authenticated user.
|
||||
* tags:
|
||||
* - Session Logs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of session recordings.
|
||||
* 500:
|
||||
* description: Failed to fetch session logs.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
hostId: sessionRecordings.hostId,
|
||||
userId: sessionRecordings.userId,
|
||||
startedAt: sessionRecordings.startedAt,
|
||||
endedAt: sessionRecordings.endedAt,
|
||||
duration: sessionRecordings.duration,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
hostName: hosts.name,
|
||||
hostIp: hosts.ip,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.orderBy(desc(sessionRecordings.startedAt));
|
||||
|
||||
const records = rows.map((row) => {
|
||||
let sizeBytes: number | null = null;
|
||||
if (row.recordingPath) {
|
||||
try {
|
||||
sizeBytes = fs.statSync(row.recordingPath).size;
|
||||
} catch {
|
||||
// file may have been removed
|
||||
}
|
||||
}
|
||||
return { ...row, sizeBytes };
|
||||
});
|
||||
|
||||
res.json({ logs: records });
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to fetch session logs", error, {
|
||||
operation: "session_logs_list",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch session logs" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session_logs/{id}:
|
||||
* get:
|
||||
* summary: Get session log metadata
|
||||
* description: Returns metadata for a single session recording.
|
||||
* tags:
|
||||
* - Session Logs
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session recording metadata.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
* description: Session log not found.
|
||||
* 500:
|
||||
* description: Failed to fetch session log.
|
||||
*/
|
||||
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const id = parseInt(rawId, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
||||
res.json({ log: rows[0] });
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to fetch session log", error, {
|
||||
operation: "session_log_get",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch session log" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session_logs/{id}/content:
|
||||
* get:
|
||||
* summary: Get session log content
|
||||
* description: Returns the raw text content of a session log file.
|
||||
* tags:
|
||||
* - Session Logs
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Raw log text.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
* description: Session log or file not found.
|
||||
* 500:
|
||||
* description: Failed to read session log.
|
||||
*/
|
||||
router.get(
|
||||
"/:id/content",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const rawId = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const id = parseInt(rawId, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionRecordings.id, id),
|
||||
eq(sessionRecordings.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0)
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const filePath = rows[0].recordingPath;
|
||||
if (!filePath)
|
||||
return res.status(404).json({ error: "No recording file" });
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
||||
if (!resolvedPath.startsWith(allowedBase)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return res.status(404).json({ error: "File not found" });
|
||||
}
|
||||
|
||||
const content = await fs.promises.readFile(resolvedPath, "utf-8");
|
||||
res.type("text/plain").send(content);
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to read session log content", error, {
|
||||
operation: "session_log_content",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read session log" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session_logs/{id}:
|
||||
* delete:
|
||||
* summary: Delete session log
|
||||
* description: Deletes a session recording and its log file.
|
||||
* tags:
|
||||
* - Session Logs
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session log deleted.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
* description: Session log not found.
|
||||
* 500:
|
||||
* description: Failed to delete session log.
|
||||
*/
|
||||
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const id = parseInt(rawId, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const filePath = rows[0].recordingPath;
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
);
|
||||
|
||||
if (filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
||||
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
|
||||
await fs.promises.unlink(resolvedPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to delete session log", error, {
|
||||
operation: "session_log_delete",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete session log" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -14,6 +14,7 @@ import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -923,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
|
||||
* /snippets:
|
||||
@@ -1158,6 +1421,24 @@ router.post(
|
||||
name,
|
||||
});
|
||||
|
||||
const { ipAddress: scIp, userAgent: scUa } = getRequestMeta(req);
|
||||
const scActor = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: scActor[0]?.username ?? userId,
|
||||
action: "create_snippet",
|
||||
resourceType: "snippet",
|
||||
resourceId: String(result[0].id),
|
||||
resourceName: name,
|
||||
ipAddress: scIp,
|
||||
userAgent: scUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.status(201).json(result[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create snippet", err);
|
||||
@@ -1274,6 +1555,24 @@ router.put(
|
||||
snippetId: parseInt(id),
|
||||
});
|
||||
|
||||
const { ipAddress: suIp, userAgent: suUa } = getRequestMeta(req);
|
||||
const suActor = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: suActor[0]?.username ?? userId,
|
||||
action: "update_snippet",
|
||||
resourceType: "snippet",
|
||||
resourceId: id,
|
||||
resourceName: existing[0].name,
|
||||
ipAddress: suIp,
|
||||
userAgent: suUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(updated[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update snippet", err);
|
||||
@@ -1340,6 +1639,24 @@ router.delete(
|
||||
snippetId: parseInt(id),
|
||||
});
|
||||
|
||||
const { ipAddress: sdIp, userAgent: sdUa } = getRequestMeta(req);
|
||||
const sdActor = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: sdActor[0]?.username ?? userId,
|
||||
action: "delete_snippet",
|
||||
resourceType: "snippet",
|
||||
resourceId: id,
|
||||
resourceName: existing[0].name,
|
||||
ipAddress: sdIp,
|
||||
userAgent: sdUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete snippet", err);
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
OIDCProviderConfig,
|
||||
} from "../../../types/index.js";
|
||||
import type { Router } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { ssoProviders } from "../db/schema.js";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
function decryptProviderConfig(
|
||||
configJson: string,
|
||||
_userId: string,
|
||||
): Record<string, unknown> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(configJson);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const field of ["client_secret", "bindPassword"] as const) {
|
||||
const val = config[field] as string | undefined;
|
||||
if (val?.startsWith("encoded:")) {
|
||||
try {
|
||||
config[field] = Buffer.from(val.substring(8), "base64").toString(
|
||||
"utf8",
|
||||
);
|
||||
} catch {
|
||||
config[field] = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function encryptProviderConfig(
|
||||
config: Record<string, unknown>,
|
||||
_userId: string,
|
||||
_providerId: string,
|
||||
): string {
|
||||
const encoded: Record<string, unknown> = { ...config };
|
||||
if (
|
||||
typeof config.client_secret === "string" &&
|
||||
!config.client_secret.startsWith("encoded:")
|
||||
) {
|
||||
encoded.client_secret = `encoded:${Buffer.from(config.client_secret).toString("base64")}`;
|
||||
}
|
||||
if (
|
||||
typeof config.bindPassword === "string" &&
|
||||
!config.bindPassword.startsWith("encoded:")
|
||||
) {
|
||||
encoded.bindPassword = `encoded:${Buffer.from(config.bindPassword).toString("base64")}`;
|
||||
}
|
||||
return JSON.stringify(encoded);
|
||||
}
|
||||
|
||||
function applyProviderDefaults(
|
||||
type: SSOProviderType,
|
||||
config: Partial<OIDCProviderConfig>,
|
||||
): Partial<OIDCProviderConfig> {
|
||||
if (type === "github") {
|
||||
return {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://github.com",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
if (type === "google") {
|
||||
return {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function registerSSOProviderRoutes(router: Router): void {
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List enabled SSO providers (public)
|
||||
* description: Returns public info for all enabled SSO providers for the login page.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of public SSO provider objects.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
const providers = await db
|
||||
.select({
|
||||
id: ssoProviders.id,
|
||||
name: ssoProviders.name,
|
||||
type: ssoProviders.type,
|
||||
displayOrder: ssoProviders.displayOrder,
|
||||
})
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.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);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/admin:
|
||||
* get:
|
||||
* summary: List all SSO providers (admin)
|
||||
* description: Returns full SSO provider list with decrypted configs for the admin panel.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of full SSO provider objects with decrypted config.
|
||||
*/
|
||||
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
|
||||
const result = rows.map((row) => ({
|
||||
...row,
|
||||
config: decryptProviderConfig(row.config, userId),
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers (admin)", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* post:
|
||||
* summary: Create SSO provider
|
||||
* description: Creates a new SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Provider created.
|
||||
* 400:
|
||||
* description: Validation error.
|
||||
*/
|
||||
router.post("/sso-providers", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config: rawConfig = {},
|
||||
} = req.body as {
|
||||
name: string;
|
||||
type: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "Provider name is required" });
|
||||
}
|
||||
const validTypes: SSOProviderType[] = [
|
||||
"oidc",
|
||||
"ldap",
|
||||
"github",
|
||||
"google",
|
||||
];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid provider type" });
|
||||
}
|
||||
|
||||
const configWithDefaults =
|
||||
type === "github" || type === "google"
|
||||
? applyProviderDefaults(
|
||||
type,
|
||||
rawConfig as Partial<OIDCProviderConfig>,
|
||||
)
|
||||
: rawConfig;
|
||||
|
||||
if (type === "oidc" || type === "github" || type === "google") {
|
||||
const c = configWithDefaults as Partial<OIDCProviderConfig>;
|
||||
const missing = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
].filter((f) => !c[f as keyof OIDCProviderConfig]);
|
||||
if (missing.length > 0 && type === "oidc") {
|
||||
return res.status(400).json({
|
||||
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
(type === "github" || type === "google") &&
|
||||
(!c.client_id || !c.client_secret)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Client ID and Client Secret are required" });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "ldap") {
|
||||
const c = configWithDefaults as Record<string, unknown>;
|
||||
const missing = [
|
||||
"host",
|
||||
"port",
|
||||
"bindDN",
|
||||
"bindPassword",
|
||||
"userSearchBase",
|
||||
"userSearchFilter",
|
||||
"usernameAttribute",
|
||||
].filter((f) => !c[f]);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tempId = `new-${Date.now()}`;
|
||||
const encryptedConfig = encryptProviderConfig(
|
||||
configWithDefaults as Record<string, unknown>,
|
||||
userId,
|
||||
tempId,
|
||||
);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(ssoProviders)
|
||||
.values({
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: encryptedConfig,
|
||||
})
|
||||
.returning();
|
||||
|
||||
authLogger.info("SSO provider created", {
|
||||
operation: "sso_provider_create",
|
||||
userId,
|
||||
type,
|
||||
providerId: inserted.id,
|
||||
});
|
||||
res.status(201).json({
|
||||
...inserted,
|
||||
config: decryptProviderConfig(inserted.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to create SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* put:
|
||||
* summary: Update SSO provider
|
||||
* description: Updates an existing SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider updated.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, providerId))
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: rawConfig,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
type?: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
let encryptedConfig = existing[0].config;
|
||||
if (rawConfig !== undefined) {
|
||||
const existingDecrypted = decryptProviderConfig(
|
||||
existing[0].config,
|
||||
userId,
|
||||
);
|
||||
const mergedConfig = {
|
||||
...JSON.parse(
|
||||
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
|
||||
),
|
||||
...rawConfig,
|
||||
};
|
||||
encryptedConfig = encryptProviderConfig(
|
||||
mergedConfig,
|
||||
userId,
|
||||
String(providerId),
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(ssoProviders)
|
||||
.set({
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(displayOrder !== undefined ? { displayOrder } : {}),
|
||||
config: encryptedConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(ssoProviders.id, providerId))
|
||||
.returning();
|
||||
|
||||
authLogger.info("SSO provider updated", {
|
||||
operation: "sso_provider_update",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({
|
||||
...updated,
|
||||
config: decryptProviderConfig(updated.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to update SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete SSO provider
|
||||
* description: Deletes an SSO provider. Blocked if users are associated.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider deleted.
|
||||
* 409:
|
||||
* description: Users are associated with this provider.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, providerId))
|
||||
.limit(1);
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const associatedUsers = db.$client
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM users WHERE sso_provider_id = ?",
|
||||
)
|
||||
.get(providerId) as { count: number };
|
||||
if (associatedUsers.count > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete provider: ${associatedUsers.count} user(s) are associated with it`,
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(ssoProviders).where(eq(ssoProviders.id, providerId));
|
||||
authLogger.info("SSO provider deleted", {
|
||||
operation: "sso_provider_delete",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({ message: "SSO provider deleted" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to delete SSO provider" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { decryptProviderConfig, encryptProviderConfig };
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Router } from "express";
|
||||
import type { RequestHandler, Router as ExpressRouter } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { getProxyAgent } from "../../utils/proxy-agent.js";
|
||||
|
||||
interface TailscaleDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
os: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
interface TailscaleAPIDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
addresses: string[];
|
||||
os: string;
|
||||
lastSeen: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
const TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2";
|
||||
|
||||
const router = Router();
|
||||
|
||||
export function registerTailscaleRoutes(
|
||||
app: ExpressRouter,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /tailscale/devices:
|
||||
* get:
|
||||
* summary: List Tailscale devices
|
||||
* description: Returns the list of devices in the configured tailnet using the stored API key.
|
||||
* tags:
|
||||
* - Tailscale
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of tailnet devices.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* devices:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* 500:
|
||||
* description: Failed to fetch Tailscale devices.
|
||||
*/
|
||||
router.get("/devices", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
const apiKey = row?.value ?? "";
|
||||
if (!apiKey) {
|
||||
return res.json({ devices: [], hasApiKey: false });
|
||||
}
|
||||
|
||||
const url = `${TAILSCALE_API_BASE}/tailnet/-/devices?fields=all`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"User-Agent": "Termix/1.0",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
apiLogger.warn("Tailscale API returned non-OK status", {
|
||||
operation: "tailscale_devices",
|
||||
status: response.status,
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid Tailscale API key", devices: [] });
|
||||
}
|
||||
return res
|
||||
.status(502)
|
||||
.json({ error: "Tailscale API error", devices: [] });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { devices: TailscaleAPIDevice[] };
|
||||
|
||||
const devices: TailscaleDevice[] = (data.devices ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
hostname: d.hostname,
|
||||
addresses: d.addresses ?? [],
|
||||
os: d.os,
|
||||
lastSeen: d.lastSeen,
|
||||
}));
|
||||
|
||||
res.json({ devices, hasApiKey: true });
|
||||
} catch (err) {
|
||||
apiLogger.error("Failed to fetch Tailscale devices", err, {
|
||||
operation: "tailscale_devices",
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch Tailscale devices", devices: [] });
|
||||
}
|
||||
});
|
||||
|
||||
app.use("/tailscale", router);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import { commandHistory, hosts } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
@@ -88,6 +88,36 @@ router.post(
|
||||
});
|
||||
}
|
||||
|
||||
const globalEnabledRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
if (globalEnabledRow && globalEnabledRow.value === "false") {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const hostRecord = await db
|
||||
.select({ enableCommandHistory: hosts.enableCommandHistory })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, parseInt(hostId, 10)))
|
||||
.limit(1);
|
||||
if (hostRecord.length > 0 && hostRecord[0].enableCommandHistory === false) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.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 bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
@@ -37,10 +41,19 @@ export function registerUserAdminRoutes(
|
||||
username: users.username,
|
||||
isAdmin: users.isAdmin,
|
||||
isOidc: users.isOidc,
|
||||
passwordHash: users.passwordHash,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
res.json({ users: allUsers });
|
||||
res.json({
|
||||
users: allUsers.map((u) => ({
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
is_admin: u.isAdmin,
|
||||
is_oidc: u.isOidc,
|
||||
password_hash: u.passwordHash ? "set" : null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list users", err);
|
||||
res.status(500).json({ error: "Failed to list users" });
|
||||
@@ -129,6 +142,50 @@ export function registerUserAdminRoutes(
|
||||
: 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 {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
@@ -150,6 +207,25 @@ export function registerUserAdminRoutes(
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const adminUserRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: adminUserRecord[0]?.username ?? userId,
|
||||
action: "make_admin",
|
||||
resourceType: "user",
|
||||
resourceId: targetUser[0].id,
|
||||
resourceName: targetUser[0].username,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: `User ${targetUser[0].username} is now an admin` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to make user admin", err);
|
||||
@@ -248,6 +324,54 @@ export function registerUserAdminRoutes(
|
||||
: 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 {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
@@ -265,6 +389,25 @@ export function registerUserAdminRoutes(
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const adminUserRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: adminUserRecord[0]?.username ?? userId,
|
||||
action: "remove_admin",
|
||||
resourceType: "user",
|
||||
resourceId: targetUser[0].id,
|
||||
resourceName: targetUser[0].username,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Admin status removed from ${targetUser[0].username}`,
|
||||
});
|
||||
@@ -273,4 +416,184 @@ export function registerUserAdminRoutes(
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -6,6 +7,7 @@ import { eq } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
export function registerUserApiKeyRoutes(
|
||||
router: Router,
|
||||
@@ -106,6 +108,29 @@ export function registerUserApiKeyRoutes(
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const actorRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
username: actorRecord[0]?.username ?? actorId,
|
||||
action: "create_api_key",
|
||||
resourceType: "api_key",
|
||||
resourceId: keyId,
|
||||
resourceName: name.trim(),
|
||||
details: JSON.stringify({
|
||||
targetUserId,
|
||||
targetUsername: targetUser[0].username,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
id: keyId,
|
||||
name: name.trim(),
|
||||
@@ -207,6 +232,25 @@ export function registerUserApiKeyRoutes(
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const actorRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, actorId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
username: actorRecord[0]?.username ?? actorId,
|
||||
action: "delete_api_key",
|
||||
resourceType: "api_key",
|
||||
resourceId: keyId,
|
||||
resourceName: existing[0].name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete API key", err, {
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv } =
|
||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
|
||||
await import("./user-oidc-utils.js");
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
@@ -61,6 +61,23 @@ describe("isOIDCUserAllowed", () => {
|
||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||
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", () => {
|
||||
@@ -132,3 +149,53 @@ describe("getOIDCConfigFromEnv", () => {
|
||||
expect(config?.scopes).toBe("openid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroups", () => {
|
||||
it("reads the standard groups claim as an array", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a comma-separated string claim", () => {
|
||||
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back through groups, roles, then group", () => {
|
||||
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
|
||||
});
|
||||
|
||||
it("reads a custom claim path when provided", () => {
|
||||
const userInfo = {
|
||||
"zitadel:grants:groups:123": ["user", "admin"],
|
||||
groups: ["ignored"],
|
||||
};
|
||||
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
|
||||
"user",
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses object keys as group names (Zitadel roles object)", () => {
|
||||
const userInfo = {
|
||||
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
|
||||
};
|
||||
expect(
|
||||
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
|
||||
).toEqual(["admin", "user"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when the custom claim is absent", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no groups are present", () => {
|
||||
expect(extractOidcGroups({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { ssoProviders } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { Agent } from "undici";
|
||||
|
||||
export type OIDCConfig = {
|
||||
client_id: string;
|
||||
@@ -12,8 +18,15 @@ export type OIDCConfig = {
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: 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 {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
@@ -43,9 +56,46 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
group_claim: process.env.OIDC_GROUP_CLAIM || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the list of group/role names from an OIDC userInfo payload.
|
||||
*
|
||||
* When `groupClaim` is set, that claim is read first (useful for providers like
|
||||
* Zitadel that nest roles under a custom path such as
|
||||
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
|
||||
* and `group` claims are tried. Values may be an array, a comma-separated
|
||||
* string, or an object whose keys are the group names.
|
||||
*/
|
||||
export function extractOidcGroups(
|
||||
userInfo: Record<string, unknown>,
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
let raw: unknown;
|
||||
if (groupClaim && groupClaim.trim()) {
|
||||
raw = userInfo[groupClaim.trim()];
|
||||
}
|
||||
if (raw === undefined || raw === null) {
|
||||
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(String);
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.keys(raw as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isOIDCUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
@@ -53,7 +103,7 @@ export function isOIDCUserAllowed(
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(",")
|
||||
.split(/[\n,]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
@@ -64,6 +114,15 @@ export function isOIDCUserAllowed(
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
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) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
@@ -80,7 +139,9 @@ export async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
issuerUrl: string,
|
||||
clientId: string,
|
||||
caCert?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
||||
? issuerUrl.slice(0, -1)
|
||||
: issuerUrl;
|
||||
@@ -99,7 +160,7 @@ export async function verifyOIDCToken(
|
||||
|
||||
try {
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
const discoveryResponse = await fetch(discoveryUrl);
|
||||
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
@@ -117,7 +178,7 @@ export async function verifyOIDCToken(
|
||||
|
||||
for (const url of jwksUrls) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (response.ok) {
|
||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
||||
@@ -170,3 +231,189 @@ export async function verifyOIDCToken(
|
||||
|
||||
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(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out = { ...config };
|
||||
for (const field of ["client_secret", "bindPassword"] as const) {
|
||||
const val = out[field] as string | undefined;
|
||||
if (val?.startsWith("encoded:")) {
|
||||
try {
|
||||
out[field] = Buffer.from(val.substring(8), "base64").toString("utf8");
|
||||
} catch {
|
||||
// leave as-is
|
||||
}
|
||||
} else if (val?.startsWith("encrypted:")) {
|
||||
// encrypted: prefix means it was encrypted with DataCrypto; without a
|
||||
// userId/dataKey here we cannot decrypt it. The caller should use the
|
||||
// full admin decrypt path when possible. Fall back to stripping prefix.
|
||||
try {
|
||||
out[field] = Buffer.from(val.substring(10), "base64").toString("utf8");
|
||||
} catch {
|
||||
// leave as-is
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function loadProviderConfig(
|
||||
providerId: number | null | undefined,
|
||||
adminUserId?: string,
|
||||
): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
if (providerId != null) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, providerId))
|
||||
.limit(1);
|
||||
if (rows.length > 0) {
|
||||
const row = rows[0];
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
if (adminUserId) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
|
||||
if (adminDataKey) {
|
||||
parsed = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
parsed,
|
||||
adminUserId,
|
||||
adminDataKey,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
parsed = decryptConfigSecret(parsed);
|
||||
}
|
||||
} else {
|
||||
parsed = decryptConfigSecret(parsed);
|
||||
}
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
return {
|
||||
config,
|
||||
providerType,
|
||||
providerDbId: row.id,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to load SSO provider config by id", err, {
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: env vars
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
// Fallback: first enabled OIDC-type provider in ssoProviders table
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy();
|
||||
const oidcRow = rows.find(
|
||||
(r) => r.type === "oidc" || r.type === "github" || r.type === "google",
|
||||
);
|
||||
if (oidcRow) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(oidcRow.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
parsed = decryptConfigSecret(parsed);
|
||||
const oidcProviderType = oidcRow.type as SSOProviderType;
|
||||
return {
|
||||
config: applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
oidcProviderType,
|
||||
),
|
||||
providerType: oidcProviderType,
|
||||
providerDbId: oidcRow.id,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy
|
||||
}
|
||||
|
||||
// Fallback: legacy settings blob
|
||||
try {
|
||||
const legacyRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (legacyRow) {
|
||||
let config = JSON.parse(legacyRow.value) as Record<string, unknown>;
|
||||
config = decryptConfigSecret(config);
|
||||
return {
|
||||
config: config as unknown as OIDCConfig,
|
||||
providerType: "oidc",
|
||||
providerDbId: null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// no legacy config
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,12 +63,19 @@ export function registerUserPasswordResetRoutes(
|
||||
*/
|
||||
router.post("/initiate-reset", async (req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
|
||||
)
|
||||
.get();
|
||||
if (row && (row as { value: string }).value !== "true") {
|
||||
const envVal = process.env.ALLOW_PASSWORD_RESET;
|
||||
const allowed =
|
||||
envVal !== undefined
|
||||
? envVal.trim().toLowerCase() === "true"
|
||||
: (() => {
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
|
||||
)
|
||||
.get();
|
||||
return row ? (row as { value: string }).value === "true" : true;
|
||||
})();
|
||||
if (!allowed) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Password reset is currently disabled" });
|
||||
@@ -123,7 +130,7 @@ export function registerUserPasswordResetRoutes(
|
||||
);
|
||||
|
||||
authLogger.info(
|
||||
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
|
||||
`Password reset code generated for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -346,8 +353,7 @@ export function registerUserPasswordResetRoutes(
|
||||
}
|
||||
const userId = user[0].id;
|
||||
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
let userIdFromJwt: string | null = null;
|
||||
const cookie = req.cookies?.jwt;
|
||||
|
||||
@@ -17,6 +17,19 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
||||
fontSize: row?.fontSize ?? null,
|
||||
accentColor: row?.accentColor ?? null,
|
||||
language: row?.language ?? null,
|
||||
storageMode: row?.storageMode ?? "cloud",
|
||||
commandAutocomplete: row?.commandAutocomplete ?? null,
|
||||
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
|
||||
showHostTags: row?.showHostTags ?? null,
|
||||
hostTrayOnClick: row?.hostTrayOnClick ?? null,
|
||||
pinAppRail: row?.pinAppRail ?? null,
|
||||
foldersCollapsed: row?.foldersCollapsed ?? null,
|
||||
confirmSnippetExecution: row?.confirmSnippetExecution ?? null,
|
||||
disableUpdateCheck: row?.disableUpdateCheck ?? null,
|
||||
confirmTabClose: row?.confirmTabClose ?? null,
|
||||
hiddenRailTabs: row?.hiddenRailTabs ?? null,
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -36,6 +49,57 @@ const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
||||
* properties:
|
||||
* reopenTabsOnLogin:
|
||||
* type: boolean
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* fontSize:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* accentColor:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* language:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* storageMode:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* showHostTags:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* hostTrayOnClick:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* pinAppRail:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* foldersCollapsed:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* confirmSnippetExecution:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* disableUpdateCheck:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* confirmTabClose:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* hiddenRailTabs:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* compactHostView:
|
||||
* type: boolean
|
||||
* nullable: true
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -72,20 +136,85 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
* properties:
|
||||
* reopenTabsOnLogin:
|
||||
* type: boolean
|
||||
* theme:
|
||||
* type: string
|
||||
* fontSize:
|
||||
* type: string
|
||||
* accentColor:
|
||||
* type: string
|
||||
* language:
|
||||
* type: string
|
||||
* storageMode:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* commandPaletteEnabled:
|
||||
* type: boolean
|
||||
* showHostTags:
|
||||
* type: boolean
|
||||
* hostTrayOnClick:
|
||||
* type: boolean
|
||||
* pinAppRail:
|
||||
* type: boolean
|
||||
* foldersCollapsed:
|
||||
* type: boolean
|
||||
* confirmSnippetExecution:
|
||||
* type: boolean
|
||||
* disableUpdateCheck:
|
||||
* type: boolean
|
||||
* confirmTabClose:
|
||||
* type: boolean
|
||||
* hiddenRailTabs:
|
||||
* type: string
|
||||
* compactHostView:
|
||||
* type: boolean
|
||||
* statusColorScheme:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
*/
|
||||
router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { reopenTabsOnLogin, theme, fontSize, accentColor, language } =
|
||||
req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
fontSize?: string | null;
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
};
|
||||
const {
|
||||
reopenTabsOnLogin,
|
||||
theme,
|
||||
fontSize,
|
||||
accentColor,
|
||||
language,
|
||||
storageMode,
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
compactHostView,
|
||||
statusColorScheme,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
fontSize?: string | null;
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
storageMode?: string | null;
|
||||
commandAutocomplete?: boolean | null;
|
||||
commandPaletteEnabled?: boolean | null;
|
||||
showHostTags?: boolean | null;
|
||||
hostTrayOnClick?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
};
|
||||
|
||||
const updates: Partial<typeof userPreferences.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -105,16 +234,56 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
fontSize,
|
||||
accentColor,
|
||||
language,
|
||||
storageMode,
|
||||
hiddenRailTabs,
|
||||
statusColorScheme,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandAutocomplete,
|
||||
commandPaletteEnabled,
|
||||
showHostTags,
|
||||
hostTrayOnClick,
|
||||
pinAppRail,
|
||||
foldersCollapsed,
|
||||
confirmSnippetExecution,
|
||||
disableUpdateCheck,
|
||||
confirmTabClose,
|
||||
compactHostView,
|
||||
};
|
||||
for (const [key, value] of Object.entries(boolFields)) {
|
||||
if (value !== undefined && value !== null && typeof value !== "boolean") {
|
||||
return res.status(400).json({ error: `${key} must be a boolean` });
|
||||
}
|
||||
}
|
||||
|
||||
if (theme !== undefined) updates.theme = theme;
|
||||
if (fontSize !== undefined) updates.fontSize = fontSize;
|
||||
if (accentColor !== undefined) updates.accentColor = accentColor;
|
||||
if (language !== undefined) updates.language = language;
|
||||
if (storageMode !== undefined) updates.storageMode = storageMode;
|
||||
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
|
||||
if (commandAutocomplete !== undefined)
|
||||
updates.commandAutocomplete = commandAutocomplete;
|
||||
if (commandPaletteEnabled !== undefined)
|
||||
updates.commandPaletteEnabled = commandPaletteEnabled;
|
||||
if (showHostTags !== undefined) updates.showHostTags = showHostTags;
|
||||
if (hostTrayOnClick !== undefined) updates.hostTrayOnClick = hostTrayOnClick;
|
||||
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
|
||||
if (foldersCollapsed !== undefined)
|
||||
updates.foldersCollapsed = foldersCollapsed;
|
||||
if (confirmSnippetExecution !== undefined)
|
||||
updates.confirmSnippetExecution = confirmSnippetExecution;
|
||||
if (disableUpdateCheck !== undefined)
|
||||
updates.disableUpdateCheck = disableUpdateCheck;
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
|
||||
if (statusColorScheme !== undefined)
|
||||
updates.statusColorScheme = statusColorScheme;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { sessions, users } from "../db/schema.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
type UserSessionRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -166,6 +167,25 @@ export function registerUserSessionRoutes(
|
||||
revokedBy: userId,
|
||||
sessionUserId: session.userId,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const actorUser = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actorUser[0]?.username ?? userId,
|
||||
action: "revoke_session",
|
||||
resourceType: "session",
|
||||
resourceId: sessionId,
|
||||
details: JSON.stringify({ targetUserId: session.userId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Session revoked successfully" });
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to revoke session" });
|
||||
@@ -244,6 +264,30 @@ export function registerUserSessionRoutes(
|
||||
revokedCount,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const actorUser = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
const targetUserRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, revokeUserId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actorUser[0]?.username ?? userId,
|
||||
action: "revoke_all_sessions",
|
||||
resourceType: "session",
|
||||
resourceId: revokeUserId,
|
||||
resourceName: targetUserRecord[0]?.username,
|
||||
details: JSON.stringify({ revokedCount, exceptCurrent }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `${revokedCount} session(s) revoked successfully`,
|
||||
count: revokedCount,
|
||||
|
||||
@@ -9,11 +9,30 @@ import {
|
||||
} from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
function getDefaultGuacUrl(): string {
|
||||
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(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
@@ -122,6 +141,24 @@ export function registerUserSettingsRoutes(
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
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_guacamole_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled, url }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
|
||||
@@ -194,6 +231,24 @@ export function registerUserSettingsRoutes(
|
||||
)
|
||||
.run(level);
|
||||
setGlobalLogLevel(level);
|
||||
|
||||
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_log_level",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ level }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ level });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set log level", err);
|
||||
@@ -267,10 +322,331 @@ export function registerUserSettingsRoutes(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
|
||||
)
|
||||
.run(String(timeoutHours));
|
||||
|
||||
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_session_timeout",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ timeoutHours }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ timeoutHours });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set session timeout", err);
|
||||
res.status(500).json({ error: "Failed to set session timeout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* get:
|
||||
* summary: Get Tailscale settings
|
||||
* description: Returns whether a Tailscale API key is configured (value is masked).
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* description: Masked API key or empty string if not set.
|
||||
* hasApiKey:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/tailscale-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'tailscale_api_key'")
|
||||
.get() as { value: string } | undefined;
|
||||
const apiKey = row?.value ?? "";
|
||||
res.json({
|
||||
apiKey: apiKey
|
||||
? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}`
|
||||
: "",
|
||||
hasApiKey: !!apiKey,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to get Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tailscale-settings:
|
||||
* patch:
|
||||
* summary: Update Tailscale settings (admin only)
|
||||
* description: Saves or clears the Tailscale API key used for device discovery.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* apiKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update Tailscale settings.
|
||||
*/
|
||||
router.patch("/tailscale-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 { apiKey } = req.body;
|
||||
if (typeof apiKey !== "string") {
|
||||
return res.status(400).json({ error: "apiKey must be a string" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('tailscale_api_key', ?)",
|
||||
)
|
||||
.run(apiKey);
|
||||
|
||||
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_tailscale_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ hasApiKey: !!apiKey }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ hasApiKey: !!apiKey });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update Tailscale settings", err);
|
||||
res.status(500).json({ error: "Failed to update Tailscale settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
* get:
|
||||
* summary: Get command history enabled setting
|
||||
* description: Returns whether command history recording is globally enabled.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command history enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/command-history-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'command_history_enabled'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({ enabled: row ? row.value !== "false" : true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get command history enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get command history enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/command-history-enabled:
|
||||
* patch:
|
||||
* summary: Update command history enabled setting (admin only)
|
||||
* description: Globally enables or disables command history recording for all hosts.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/command-history-enabled",
|
||||
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: "enabled must be a boolean" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('command_history_enabled', ?)",
|
||||
)
|
||||
.run(enabled ? "true" : "false");
|
||||
|
||||
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_command_history_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update command history enabled setting",
|
||||
err,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update command history enabled setting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import speakeasy from "speakeasy";
|
||||
|
||||
// The route module imports the db barrel (which has filesystem/crypto side
|
||||
// effects on import) plus the logger; stub both so importing stays inert.
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateSet = vi.fn(() => ({ where: updateWhere }));
|
||||
const dbUpdate = vi.fn(() => ({ set: updateSet }));
|
||||
|
||||
vi.mock("../db/index.js", () => ({
|
||||
db: {
|
||||
update: dbUpdate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { verifyTotpReauth } = await import("./user-totp-routes.js");
|
||||
|
||||
type AnyUser = Parameters<typeof verifyTotpReauth>[0];
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: "test" }).base32;
|
||||
|
||||
function makeUser(overrides: Partial<AnyUser> = {}): AnyUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
isOidc: false,
|
||||
passwordHash: bcrypt.hashSync("correct-horse", 4),
|
||||
totpSecret: secret,
|
||||
totpBackupCodes: JSON.stringify(["BACKUP01", "BACKUP02"]),
|
||||
totpEnabled: true,
|
||||
...overrides,
|
||||
} as AnyUser;
|
||||
}
|
||||
|
||||
describe("verifyTotpReauth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("accepts the correct password", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a valid TOTP code without a password", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
expect(await verifyTotpReauth(makeUser(), token)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a valid backup code and consumes it", async () => {
|
||||
const result = await verifyTotpReauth(makeUser(), "BACKUP01");
|
||||
expect(result).toBe(true);
|
||||
expect(updateSet).toHaveBeenCalledWith({
|
||||
totpBackupCodes: JSON.stringify(["BACKUP02"]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong password / invalid code", async () => {
|
||||
expect(await verifyTotpReauth(makeUser(), "wrong")).toBe(false);
|
||||
expect(updateSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores the password path for OIDC users but still accepts TOTP", async () => {
|
||||
const token = speakeasy.totp({ secret, encoding: "base32" });
|
||||
const oidcUser = makeUser({ isOidc: true, passwordHash: null });
|
||||
expect(await verifyTotpReauth(oidcUser, token)).toBe(true);
|
||||
expect(await verifyTotpReauth(oidcUser, "anything")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed backup-code JSON without throwing", async () => {
|
||||
const user = makeUser({ totpBackupCodes: "not json" });
|
||||
expect(await verifyTotpReauth(user, "BACKUP01")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
@@ -23,6 +24,86 @@ interface UserTotpRoutesDeps {
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
type TotpUserRecord = typeof users.$inferSelect;
|
||||
|
||||
export async function verifyTotpReauth(
|
||||
userRecord: TotpUserRecord,
|
||||
credential: string,
|
||||
userDataKey?: Buffer | null,
|
||||
): Promise<boolean> {
|
||||
if (!userRecord.isOidc && userRecord.passwordHash) {
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
credential,
|
||||
userRecord.passwordHash,
|
||||
);
|
||||
if (passwordMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (userRecord.totpSecret) {
|
||||
const totpSecret = userDataKey
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpSecret",
|
||||
)
|
||||
: userRecord.totpSecret;
|
||||
|
||||
if (totpSecret) {
|
||||
const totpMatch = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: credential,
|
||||
window: 2,
|
||||
});
|
||||
if (totpMatch) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawBackupCodes =
|
||||
userDataKey && userRecord.totpBackupCodes
|
||||
? LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpBackupCodes,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: userRecord.totpBackupCodes;
|
||||
|
||||
let backupCodes: unknown = [];
|
||||
try {
|
||||
backupCodes = rawBackupCodes ? JSON.parse(rawBackupCodes) : [];
|
||||
} catch {
|
||||
backupCodes = [];
|
||||
}
|
||||
if (Array.isArray(backupCodes)) {
|
||||
const backupIndex = backupCodes.indexOf(credential);
|
||||
if (backupIndex !== -1) {
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
const updatedJson = JSON.stringify(backupCodes);
|
||||
const storedValue = userDataKey
|
||||
? FieldCrypto.encryptField(
|
||||
updatedJson,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totpBackupCodes",
|
||||
)
|
||||
: updatedJson;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpBackupCodes: storedValue })
|
||||
.where(eq(users.id, userRecord.id));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerUserTotpRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
|
||||
@@ -113,6 +194,7 @@ export function registerUserTotpRoutes(
|
||||
*/
|
||||
router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
const { totp_code } = req.body;
|
||||
|
||||
if (!totp_code) {
|
||||
@@ -120,6 +202,21 @@ export function registerUserTotpRoutes(
|
||||
}
|
||||
|
||||
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));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
@@ -135,8 +232,18 @@ export function registerUserTotpRoutes(
|
||||
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({
|
||||
secret: userRecord.totpSecret,
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
@@ -150,15 +257,31 @@ export function registerUserTotpRoutes(
|
||||
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
|
||||
.update(users)
|
||||
.set({
|
||||
totpEnabled: true,
|
||||
totpBackupCodes: JSON.stringify(backupCodes),
|
||||
totpBackupCodes: storedBackupCodes,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
sessionId
|
||||
? and(eq(sessions.userId, userId), ne(sessions.id, sessionId))
|
||||
: eq(sessions.userId, userId),
|
||||
);
|
||||
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
|
||||
|
||||
try {
|
||||
@@ -219,11 +342,12 @@ export function registerUserTotpRoutes(
|
||||
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
const credential = password || totp_code;
|
||||
|
||||
if (!password || !totp_code) {
|
||||
if (!credential) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Both password and TOTP code are required" });
|
||||
.json({ error: "A TOTP code or password is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -238,22 +362,16 @@ export function registerUserTotpRoutes(
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.isOidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totpSecret!,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
const verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Incorrect password or invalid TOTP code" });
|
||||
}
|
||||
|
||||
await db
|
||||
@@ -310,11 +428,12 @@ export function registerUserTotpRoutes(
|
||||
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
const credential = password || totp_code;
|
||||
|
||||
if (!password || !totp_code) {
|
||||
if (!credential) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Both password and TOTP code are required" });
|
||||
.json({ error: "A TOTP code or password is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -329,31 +448,35 @@ export function registerUserTotpRoutes(
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.isOidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totpSecret!,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userId);
|
||||
const verified = await verifyTotpReauth(
|
||||
userRecord,
|
||||
credential,
|
||||
userDataKey,
|
||||
);
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Incorrect password or invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
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
|
||||
.update(users)
|
||||
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
|
||||
.set({ totpBackupCodes: storedBackupCodes })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
res.json({ backup_codes: backupCodes });
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.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 { nanoid } from "nanoid";
|
||||
import type { Request, Response } from "express";
|
||||
@@ -20,15 +20,22 @@ import {
|
||||
getOIDCConfigFromEnv,
|
||||
isOIDCUserAllowed,
|
||||
verifyOIDCToken,
|
||||
extractOidcGroups,
|
||||
loadProviderConfig,
|
||||
buildFetchOptions,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||
import { registerUserTotpRoutes } from "./user-totp-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
|
||||
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
@@ -38,6 +45,45 @@ function isNonEmptyString(val: unknown): val is string {
|
||||
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 {
|
||||
return (
|
||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||
@@ -80,20 +126,10 @@ const requireAdmin = authManager.createAdminMiddleware();
|
||||
* description: Failed to create user.
|
||||
*/
|
||||
router.post("/create", async (req, res) => {
|
||||
try {
|
||||
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
|
||||
.status(403)
|
||||
.json({ error: "Registration is currently disabled" });
|
||||
}
|
||||
} catch (e) {
|
||||
authLogger.warn("Failed to check registration status", {
|
||||
operation: "registration_check",
|
||||
error: e,
|
||||
});
|
||||
if (!isRegistrationAllowed()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Registration is currently disabled" });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
@@ -130,8 +166,7 @@ router.post("/create", async (req, res) => {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const id = nanoid();
|
||||
|
||||
const isFirstUser = db.$client.transaction(() => {
|
||||
@@ -225,6 +260,20 @@ router.post("/create", async (req, res) => {
|
||||
username,
|
||||
isAdmin: isFirstUser,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: id,
|
||||
username,
|
||||
action: "create_user",
|
||||
resourceType: "user",
|
||||
resourceId: id,
|
||||
resourceName: username,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "User created",
|
||||
is_admin: isFirstUser,
|
||||
@@ -272,6 +321,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
scopes,
|
||||
allowed_users,
|
||||
admin_group,
|
||||
group_claim,
|
||||
} = req.body;
|
||||
|
||||
const isDisableRequest =
|
||||
@@ -331,6 +381,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
scopes: scopes || "openid email profile",
|
||||
allowed_users: allowed_users || "",
|
||||
admin_group: admin_group || "",
|
||||
group_claim: group_claim || "",
|
||||
};
|
||||
|
||||
let encryptedConfig;
|
||||
@@ -440,35 +491,19 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
* 500:
|
||||
* description: Failed to get OIDC config.
|
||||
*/
|
||||
router.get("/oidc-config", async (req, res) => {
|
||||
router.get("/oidc-config", async (_req, res) => {
|
||||
try {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
return res.json({
|
||||
client_id: envConfig.client_id,
|
||||
issuer_url: envConfig.issuer_url,
|
||||
authorization_url: envConfig.authorization_url,
|
||||
scopes: envConfig.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get();
|
||||
if (!row) {
|
||||
const providerResult = await loadProviderConfig(undefined);
|
||||
if (!providerResult) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
|
||||
const publicConfig = {
|
||||
const { config } = providerResult;
|
||||
return res.json({
|
||||
client_id: config.client_id,
|
||||
issuer_url: config.issuer_url,
|
||||
authorization_url: config.authorization_url,
|
||||
scopes: config.scopes,
|
||||
};
|
||||
|
||||
return res.json(publicConfig);
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config" });
|
||||
@@ -569,24 +604,25 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
*/
|
||||
router.get("/oidc/authorize", async (req, res) => {
|
||||
try {
|
||||
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
||||
const {
|
||||
rememberMe,
|
||||
desktopCallbackPort,
|
||||
appCallbackUrl,
|
||||
providerId: providerIdStr,
|
||||
} = req.query;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
let config;
|
||||
|
||||
if (envConfig) {
|
||||
config = envConfig;
|
||||
} else {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get();
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: "OIDC not configured" });
|
||||
}
|
||||
config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
resolvedProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
return res.status(404).json({ error: "OIDC not configured" });
|
||||
}
|
||||
const { config, providerDbId } = providerResult;
|
||||
const state = nanoid();
|
||||
const nonce = nanoid();
|
||||
|
||||
@@ -631,6 +667,12 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
|
||||
if (providerDbId != null) {
|
||||
db.$client
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(`oidc_provider_${state}`, String(providerDbId));
|
||||
}
|
||||
|
||||
const authUrl = new URL(config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
||||
@@ -699,33 +741,258 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
return res.status(400).json({ error: "Invalid state parameter" });
|
||||
}
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
let config;
|
||||
const storedProviderIdRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(`oidc_provider_${state}`) as { value: string } | null;
|
||||
const callbackProviderId = storedProviderIdRow
|
||||
? parseInt(storedProviderIdRow.value, 10)
|
||||
: null;
|
||||
|
||||
if (envConfig) {
|
||||
config = envConfig;
|
||||
} else {
|
||||
const configRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get();
|
||||
if (!configRow) {
|
||||
return res.status(500).json({ error: "OIDC not configured" });
|
||||
}
|
||||
config = JSON.parse(
|
||||
(configRow as Record<string, unknown>).value as string,
|
||||
);
|
||||
const providerResult = await loadProviderConfig(
|
||||
callbackProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
return res.status(500).json({ error: "OIDC not configured" });
|
||||
}
|
||||
const {
|
||||
config,
|
||||
providerType: callbackProviderType,
|
||||
providerDbId: callbackProviderDbId,
|
||||
} = providerResult;
|
||||
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(10),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
// Clean up provider state key
|
||||
if (storedProviderIdRow) {
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.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
|
||||
if (callbackProviderType === "github") {
|
||||
const ghTokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code: code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!ghTokenResponse.ok) {
|
||||
const errorText = await ghTokenResponse.text();
|
||||
authLogger.error("GitHub token exchange failed", {
|
||||
operation: "github_token_exchange_failed",
|
||||
status: ghTokenResponse.status,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Failed to exchange authorization code" });
|
||||
}
|
||||
|
||||
const ghTokenData = (await ghTokenResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`oidc_state_${state}`);
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`oidc_backend_callback_${state}`);
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`oidc_frontend_origin_${state}`);
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`oidc_remember_me_${state}`);
|
||||
|
||||
const ghUserInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghTokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!ghUserInfoResponse.ok) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Failed to get GitHub user information" });
|
||||
}
|
||||
const ghUserInfo = (await ghUserInfoResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const emailRes = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghTokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
if (emailRes.ok) {
|
||||
const emails = (await emailRes.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) ghUserInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const ghIdentifier = `github:${callbackProviderDbId}:${String(ghUserInfo.id ?? ghUserInfo.login)}`;
|
||||
const ghName = (ghUserInfo.name ||
|
||||
ghUserInfo.login ||
|
||||
ghIdentifier) as string;
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
let ghUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.oidcIdentifier, ghIdentifier));
|
||||
if (!ghUser || ghUser.length === 0) {
|
||||
const preCheckCount = db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get() as { count?: number };
|
||||
const isFirstUser = (preCheckCount?.count || 0) === 0;
|
||||
|
||||
if (!isFirstUser && config.allowed_users) {
|
||||
const email = ghUserInfo.email as string | undefined;
|
||||
if (!isOIDCUserAllowed(config.allowed_users, ghIdentifier, email)) {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("error", "user_not_allowed");
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
let ghAutoProvision = false;
|
||||
try {
|
||||
const r = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
if (r) ghAutoProvision = r.value === "true";
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (!ghAutoProvision)
|
||||
ghAutoProvision =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
|
||||
if (!isFirstUser && !ghAutoProvision) {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
|
||||
const ghId = nanoid();
|
||||
const ghIsFirst = db.$client.transaction(() => {
|
||||
const c =
|
||||
(
|
||||
db.$client
|
||||
.prepare("SELECT COUNT(*) as count FROM users")
|
||||
.get() as { count?: number }
|
||||
)?.count || 0;
|
||||
const first = c === 0;
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
ghId,
|
||||
ghName,
|
||||
"",
|
||||
first ? 1 : 0,
|
||||
ghIdentifier,
|
||||
callbackProviderDbId,
|
||||
);
|
||||
return first;
|
||||
})();
|
||||
|
||||
try {
|
||||
const defaultRoleName = ghIsFirst ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
if (defaultRole.length > 0)
|
||||
await db.insert(userRoles).values({
|
||||
userId: ghId,
|
||||
roleId: defaultRole[0].id,
|
||||
grantedBy: ghId,
|
||||
});
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(ghId, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, ghId));
|
||||
return res.status(500).json({
|
||||
error: "Failed to setup user security - user creation cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
ghUser = await db.select().from(users).where(eq(users.id, ghId));
|
||||
}
|
||||
|
||||
const ghUserRecord = ghUser[0];
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(
|
||||
ghUserRecord.id,
|
||||
deviceInfo.type,
|
||||
);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
const ghToken = await authManager.generateJWTToken(ghUserRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
rememberMe: storedRememberMe,
|
||||
});
|
||||
const ghRedirectUrl = new URL(frontendOrigin);
|
||||
ghRedirectUrl.searchParams.set("success", "true");
|
||||
const ghIsTokenCallback =
|
||||
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
||||
frontendOrigin.startsWith("termix-mobile:");
|
||||
const ghMaxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: storedRememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 24 * 60 * 60 * 1000;
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
if (ghIsTokenCallback) {
|
||||
ghRedirectUrl.searchParams.set("token", ghToken);
|
||||
return res.redirect(ghRedirectUrl.toString());
|
||||
}
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
ghToken,
|
||||
authManager.getSecureCookieOptions(req, ghMaxAge),
|
||||
)
|
||||
.redirect(ghRedirectUrl.toString());
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
@@ -741,6 +1008,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
code: code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
@@ -783,7 +1051,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
try {
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
const discoveryResponse = await fetch(discoveryUrl);
|
||||
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
@@ -818,6 +1086,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
tokenData.id_token as string,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
caCert,
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
@@ -834,20 +1103,22 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInfo && tokenData.access_token) {
|
||||
if (tokenData.access_token) {
|
||||
for (const userInfoUrl of userInfoUrls) {
|
||||
try {
|
||||
const userInfoResponse = await fetch(userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (userInfoResponse.ok) {
|
||||
userInfo = (await userInfoResponse.json()) as Record<
|
||||
const fetchedUserInfo = (await userInfoResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
userInfo = { ...userInfo, ...fetchedUserInfo };
|
||||
break;
|
||||
} else {
|
||||
authLogger.error(
|
||||
@@ -949,33 +1220,17 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
}
|
||||
|
||||
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(
|
||||
"OIDC user attempted to register when registration is disabled",
|
||||
{
|
||||
operation: "oidc_registration_disabled",
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
);
|
||||
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
authLogger.warn("Failed to check registration status during OIDC", {
|
||||
operation: "oidc_registration_check",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
authLogger.warn(
|
||||
"OIDC user attempted to register but auto-provisioning is disabled",
|
||||
{
|
||||
operation: "oidc_registration_disabled",
|
||||
identifier,
|
||||
name,
|
||||
},
|
||||
);
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("error", "registration_disabled");
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
@@ -986,7 +1241,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const first = (countResult?.count || 0) === 0;
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, sso_provider_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
@@ -995,14 +1250,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
first ? 1 : 0,
|
||||
1,
|
||||
identifier,
|
||||
String(config.client_id),
|
||||
String(config.client_secret),
|
||||
String(config.issuer_url),
|
||||
String(config.authorization_url),
|
||||
String(config.token_url),
|
||||
String(config.identifier_path),
|
||||
String(config.name_path),
|
||||
String(config.scopes),
|
||||
callbackProviderDbId,
|
||||
);
|
||||
return first;
|
||||
})();
|
||||
@@ -1107,14 +1355,65 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
// Sync admin status based on OIDC group membership
|
||||
if (config.admin_group) {
|
||||
const groups = (userInfo.groups || userInfo.roles || []) as string[];
|
||||
const groups = extractOidcGroups(
|
||||
userInfo as Record<string, unknown>,
|
||||
config.group_claim,
|
||||
);
|
||||
|
||||
authLogger.info(
|
||||
`Evaluating OIDC admin group sync. parsedGroups: ${JSON.stringify(groups)}, configuredAdminGroup: ${config.admin_group}, groupClaim: ${config.group_claim || "(default)"}, availableUserInfoKeys: ${Object.keys(userInfo).join(",")}`,
|
||||
{
|
||||
operation: "oidc_admin_group_sync_eval",
|
||||
userId: userRecord.id,
|
||||
},
|
||||
);
|
||||
|
||||
const shouldBeAdmin = groups.includes(config.admin_group);
|
||||
if (!!userRecord.isAdmin !== shouldBeAdmin) {
|
||||
authLogger.info("Syncing admin status based on OIDC group membership", {
|
||||
operation: "oidc_admin_group_sync",
|
||||
userId: userRecord.id,
|
||||
group: config.admin_group,
|
||||
isAdmin: shouldBeAdmin,
|
||||
});
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: shouldBeAdmin })
|
||||
.where(eq(users.id, userRecord.id));
|
||||
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", {
|
||||
operation: "oidc_admin_group_sync",
|
||||
userId: userRecord.id,
|
||||
@@ -1252,21 +1551,10 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
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
|
||||
.status(403)
|
||||
.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" });
|
||||
if (!isPasswordLoginAllowed()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Password authentication is currently disabled" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1411,6 +1699,17 @@ router.post("/login", async (req, res) => {
|
||||
sessionId: payload?.sessionId,
|
||||
});
|
||||
|
||||
const { ipAddress: loginIp, userAgent: loginUa } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: userRecord.id,
|
||||
username,
|
||||
action: "login",
|
||||
resourceType: "session",
|
||||
ipAddress: loginIp,
|
||||
userAgent: loginUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
@@ -1656,12 +1955,7 @@ router.get("/db-health", requireAdmin, async (req, res) => {
|
||||
*/
|
||||
router.get("/registration-allowed", async (req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
|
||||
.get();
|
||||
res.json({
|
||||
allowed: row ? (row as Record<string, unknown>).value === "true" : true,
|
||||
});
|
||||
res.json({ allowed: isRegistrationAllowed() });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get registration allowed", err);
|
||||
res.status(500).json({ error: "Failed to get registration allowed" });
|
||||
@@ -1766,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
|
||||
* /users/password-login-allowed:
|
||||
@@ -1782,12 +2177,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.get("/password-login-allowed", async (req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
.get();
|
||||
res.json({
|
||||
allowed: row ? (row as { value: string }).value === "true" : true,
|
||||
});
|
||||
res.json({ allowed: isPasswordLoginAllowed() });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get password login allowed", err);
|
||||
res.status(500).json({ error: "Failed to get password login allowed" });
|
||||
@@ -1832,6 +2222,17 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
||||
if (typeof allowed !== "boolean") {
|
||||
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
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
||||
@@ -1862,12 +2263,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.get("/password-reset-allowed", async (req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_reset'")
|
||||
.get();
|
||||
res.json({
|
||||
allowed: row ? (row as { value: string }).value === "true" : true,
|
||||
});
|
||||
res.json({ allowed: isPasswordResetAllowed() });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get password reset allowed", err);
|
||||
res.status(500).json({ error: "Failed to get password reset allowed" });
|
||||
@@ -2084,8 +2480,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
.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, saltRounds);
|
||||
const password_hash = await bcrypt.hash(newPassword, 10);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: password_hash })
|
||||
@@ -2097,6 +2492,23 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
userId,
|
||||
});
|
||||
|
||||
const { ipAddress: pwIp, userAgent: pwUa } = getRequestMeta(req);
|
||||
const pwUser = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: pwUser[0]?.username ?? userId,
|
||||
action: "change_password",
|
||||
resourceType: "user",
|
||||
resourceId: userId,
|
||||
ipAddress: pwIp,
|
||||
userAgent: pwUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: "Password changed successfully. Please log in again." });
|
||||
});
|
||||
|
||||
@@ -2184,6 +2596,25 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
|
||||
targetUserId,
|
||||
targetUsername: username,
|
||||
});
|
||||
|
||||
const { ipAddress: deleteIp, userAgent: deleteUa } = getRequestMeta(req);
|
||||
const delAdminRecord = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: delAdminRecord[0]?.username ?? userId,
|
||||
action: "delete_user",
|
||||
resourceType: "user",
|
||||
resourceId: targetUserId,
|
||||
resourceName: username,
|
||||
ipAddress: deleteIp,
|
||||
userAgent: deleteUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: `User ${username} deleted successfully` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete user", err);
|
||||
@@ -2219,7 +2650,11 @@ registerUserOidcAccountRoutes(router, {
|
||||
});
|
||||
|
||||
registerUserSettingsRoutes(router, authenticateJWT);
|
||||
registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
|
||||
registerSSOProviderRoutes(router);
|
||||
registerLDAPAuthRoutes(router);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,8 +5,8 @@ import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
@@ -67,9 +67,15 @@ router.use(authManager.createAuthMiddleware());
|
||||
*/
|
||||
router.post("/token", async (req, res) => {
|
||||
try {
|
||||
const { type, hostname, port, username, password, domain, ...options } =
|
||||
const { type, hostname, port, username, password, domain, ...rawOptions } =
|
||||
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) {
|
||||
return res
|
||||
.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) {
|
||||
const parsed = parseInt(String(guacConfig.dpi), 10);
|
||||
guacConfig.dpi =
|
||||
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 hostname = host.ip as string;
|
||||
let port = host.port as number;
|
||||
@@ -385,6 +517,15 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const guacdOverrides = {
|
||||
...(perConnectionGuacdHost
|
||||
? { guacdHost: perConnectionGuacdHost }
|
||||
: {}),
|
||||
...(perConnectionGuacdPort
|
||||
? { guacdPort: perConnectionGuacdPort }
|
||||
: {}),
|
||||
};
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||
@@ -405,6 +546,7 @@ router.post(
|
||||
? !!host.ignoreCert
|
||||
: true,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
break;
|
||||
case "vnc":
|
||||
@@ -416,6 +558,7 @@ router.post(
|
||||
port,
|
||||
security: "any",
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
);
|
||||
break;
|
||||
@@ -423,6 +566,7 @@ router.post(
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -3,6 +3,8 @@ import { guacLogger } from "../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
settings: {
|
||||
hostname: string;
|
||||
port?: number;
|
||||
@@ -120,18 +122,24 @@ export class GuacamoleTokenService {
|
||||
hostname: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "rdp",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
port: 3389,
|
||||
"ignore-cert": true,
|
||||
...options,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -142,17 +150,23 @@ export class GuacamoleTokenService {
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "vnc",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
...(username ? { username } : {}),
|
||||
password,
|
||||
port: 5900,
|
||||
...options,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -163,17 +177,23 @@ export class GuacamoleTokenService {
|
||||
hostname: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||
options: Partial<GuacamoleConnectionSettings["settings"]> & {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
type: "telnet",
|
||||
...(guacdHost ? { guacdHost } : {}),
|
||||
...(guacdPort ? { guacdPort } : {}),
|
||||
settings: {
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port: 23,
|
||||
...options,
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ interface HostConfig {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
useWarpgate?: boolean;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
@@ -112,6 +113,7 @@ export class SSHAuthManager {
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
@@ -143,7 +145,7 @@ export class SSHAuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials);
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
}
|
||||
|
||||
private handleWarpgateAuth(
|
||||
@@ -282,7 +284,22 @@ export class SSHAuthManager {
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): 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 =
|
||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||
|
||||
@@ -290,19 +307,34 @@ export class SSHAuthManager {
|
||||
/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) {
|
||||
return;
|
||||
}
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
const promptIndex =
|
||||
passwordPromptIndex !== -1 && !hasStoredPassword
|
||||
? passwordPromptIndex
|
||||
: firstUnansweredIndex;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === passwordPromptIndex) {
|
||||
if (index === promptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
@@ -335,7 +367,7 @@ export class SSHAuthManager {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
|
||||
describe("pickResolvedUsername", () => {
|
||||
it("keeps the host username when one is set, even with a credential username", () => {
|
||||
expect(pickResolvedUsername("admin", "root", false)).toBe("admin");
|
||||
});
|
||||
|
||||
it("falls back to the credential username when the host has none", () => {
|
||||
expect(pickResolvedUsername("", "root", false)).toBe("root");
|
||||
expect(pickResolvedUsername(undefined, "root", false)).toBe("root");
|
||||
expect(pickResolvedUsername(" ", "root", false)).toBe("root");
|
||||
});
|
||||
|
||||
it("treats whitespace-only host usernames as empty", () => {
|
||||
expect(pickResolvedUsername(" ", "deploy", false)).toBe("deploy");
|
||||
});
|
||||
|
||||
it("forces the host username when overrideCredentialUsername is set", () => {
|
||||
expect(pickResolvedUsername("admin", "root", true)).toBe("admin");
|
||||
expect(pickResolvedUsername("", "root", true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when neither username is usable", () => {
|
||||
expect(pickResolvedUsername("", "", 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Decides which username to use when a host is backed by a saved credential.
|
||||
*
|
||||
* An explicitly-set host username always wins - if the user typed a username on
|
||||
* the host it should be honoured even when a credential is attached. The
|
||||
* credential's username is only used as a fallback when the host has none. The
|
||||
* `overrideCredentialUsername` flag forces the host username regardless.
|
||||
*/
|
||||
export function pickResolvedUsername(
|
||||
hostUsername: unknown,
|
||||
credentialUsername: unknown,
|
||||
overrideCredentialUsername?: unknown,
|
||||
): string | undefined {
|
||||
const host = isNonEmptyString(hostUsername) ? hostUsername : undefined;
|
||||
const cred = isNonEmptyString(credentialUsername)
|
||||
? credentialUsername
|
||||
: undefined;
|
||||
|
||||
if (overrideCredentialUsername) return host;
|
||||
if (host) return host;
|
||||
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 {
|
||||
return typeof value === "string" && value.trim() !== "";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type DockerSession = {
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
isWindows?: boolean;
|
||||
};
|
||||
|
||||
type PendingDockerTotpSession = unknown;
|
||||
@@ -93,7 +94,10 @@ export function registerDockerContainerRoutes(
|
||||
|
||||
try {
|
||||
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(
|
||||
session,
|
||||
@@ -916,7 +920,7 @@ export function registerDockerContainerRoutes(
|
||||
session.activeOperations++;
|
||||
|
||||
try {
|
||||
let command = `docker logs ${containerId} 2>&1`;
|
||||
let command = `docker logs ${containerId}`;
|
||||
|
||||
if (tail && tail > 0) {
|
||||
command += ` --tail ${Math.floor(tail)}`;
|
||||
@@ -934,6 +938,8 @@ export function registerDockerContainerRoutes(
|
||||
command += ` --until ${until}`;
|
||||
}
|
||||
|
||||
command += " 2>&1";
|
||||
|
||||
const logs = await executeDockerCommand(
|
||||
session,
|
||||
command,
|
||||
@@ -1026,7 +1032,10 @@ export function registerDockerContainerRoutes(
|
||||
session.activeOperations++;
|
||||
|
||||
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(
|
||||
session,
|
||||
|
||||
@@ -44,6 +44,7 @@ interface SSHSession {
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
isWindows?: boolean;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -314,7 +315,9 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
tryKeyboard:
|
||||
jumpHostConfig.authType !== "none" &&
|
||||
jumpHostConfig.authType !== "tailscale",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
@@ -848,8 +851,12 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
),
|
||||
};
|
||||
|
||||
if (resolvedCredentials.authType === "none") {
|
||||
// no credentials needed
|
||||
if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale" ||
|
||||
resolvedCredentials.authType === "warpgate"
|
||||
) {
|
||||
// Tailscale SSH, "none", and Warpgate auth: no static credentials
|
||||
} else if (resolvedCredentials.authType === "password") {
|
||||
if (resolvedCredentials.password) {
|
||||
config.password = resolvedCredentials.password;
|
||||
@@ -1008,7 +1015,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
connectionLogs.push(
|
||||
createConnectionLog("info", "auth", "Authenticating with SSH key"),
|
||||
);
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
} else if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale"
|
||||
) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -1030,7 +1040,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
|
||||
sshSessions[sessionId] = {
|
||||
const session: SSHSession = {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
@@ -1039,8 +1049,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
userId,
|
||||
};
|
||||
|
||||
sshSessions[sessionId] = session;
|
||||
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({
|
||||
success: true,
|
||||
message: "SSH connection established",
|
||||
@@ -1144,7 +1170,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
(err.message.includes("authentication") ||
|
||||
err.message.includes("All configured authentication methods failed"))
|
||||
) {
|
||||
@@ -1304,8 +1331,14 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (resolvedCredentials.authType === "warpgate") {
|
||||
finish(prompts.map(() => ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
passwordPromptIndex !== -1
|
||||
) {
|
||||
if (responseSent) return;
|
||||
@@ -2134,7 +2167,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
|
||||
try {
|
||||
await executeDockerCommand(
|
||||
session,
|
||||
"docker ps >/dev/null 2>&1",
|
||||
"docker ps",
|
||||
sessionId,
|
||||
userId,
|
||||
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 { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
@@ -1302,4 +1303,208 @@ export function registerFileContentRoutes(
|
||||
|
||||
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 { fileLogger } from "../utils/logger.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 = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
@@ -10,6 +14,37 @@ type FileDownloadRoutesDeps = {
|
||||
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(
|
||||
app: Express,
|
||||
{
|
||||
@@ -23,7 +58,7 @@ export function registerFileDownloadRoutes(
|
||||
* /ssh/file_manager/ssh/downloadFile:
|
||||
* post:
|
||||
* 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:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
@@ -88,6 +123,45 @@ export function registerFileDownloadRoutes(
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
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", {
|
||||
operation: "file_sftp_open",
|
||||
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) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -188,6 +289,43 @@ export function registerFileDownloadRoutes(
|
||||
|
||||
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 {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
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" });
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
execWithSudo,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
import { isWindowsSftpPath } from "./transfer-paths.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
@@ -373,11 +374,23 @@ export function registerFileOperationRoutes(
|
||||
type: isDirectory ? "directory" : "file",
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}'`
|
||||
: `rm -f '${escapedPath}'`;
|
||||
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 -f '${escapedPath}'`;
|
||||
}
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -449,7 +462,7 @@ export function registerFileOperationRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputData.includes("SUCCESS") || code === 0) {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Item deleted successfully", {
|
||||
operation: "file_delete_success",
|
||||
sessionId,
|
||||
@@ -465,8 +478,18 @@ export function registerFileOperationRoutes(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const detail =
|
||||
errorData.trim() ||
|
||||
outputData.trim() ||
|
||||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
||||
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
|
||||
operation: "file_delete_failed",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
error: `Delete failed: ${detail}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface SSHSession {
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
scpLegacy?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
|
||||
+133
-19
@@ -132,6 +132,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
client: SSHClient,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { ip, port, username } = host;
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id);
|
||||
const config: Record<string, unknown> = {
|
||||
host: ip?.replace(/^\[|\]$/g, "") || ip,
|
||||
port,
|
||||
@@ -149,6 +150,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
preloadedHostData,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
@@ -220,7 +222,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
token,
|
||||
username,
|
||||
);
|
||||
} else if (authType !== "none") {
|
||||
} else if (authType !== "none" && authType !== "tailscale") {
|
||||
throw new Error(`Unsupported auth type for transfer: ${authType}`);
|
||||
}
|
||||
|
||||
@@ -725,6 +727,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
let resolvedIp = ip;
|
||||
let resolvedPort = port;
|
||||
let resolvedUsername = username;
|
||||
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) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
@@ -742,6 +752,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
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 (
|
||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||
resolvedHost.jumpHosts &&
|
||||
resolvedHost.jumpHosts.length > 0
|
||||
) {
|
||||
resolvedJumpHosts = resolvedHost.jumpHosts;
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"jump",
|
||||
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
|
||||
),
|
||||
);
|
||||
}
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -775,6 +808,29 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
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 (
|
||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||
resolvedHost.jumpHosts &&
|
||||
resolvedHost.jumpHosts.length > 0
|
||||
) {
|
||||
resolvedJumpHosts = resolvedHost.jumpHosts;
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"jump",
|
||||
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
|
||||
),
|
||||
);
|
||||
}
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -793,6 +849,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId);
|
||||
const config: Record<string, unknown> = {
|
||||
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
|
||||
port: resolvedPort,
|
||||
@@ -800,10 +857,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval * 1000
|
||||
? Math.max(5000, hostKeepaliveInterval * 1000)
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
||||
typeof hostKeepaliveCountMax === "number"
|
||||
? Math.max(1, hostKeepaliveCountMax)
|
||||
: 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -814,6 +873,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
preloadedHostData,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
@@ -984,7 +1044,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
} else if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale" ||
|
||||
resolvedCredentials.authType === "warpgate"
|
||||
) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -1077,6 +1141,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
hostId,
|
||||
username,
|
||||
sudoPassword: resolvedCredentials.sudoPassword,
|
||||
scpLegacy: resolvedScpLegacy,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({
|
||||
@@ -1234,7 +1299,16 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
err.message.includes("Cannot parse privateKey") &&
|
||||
err.message.includes("no passphrase")
|
||||
) {
|
||||
res.json({
|
||||
status: "passphrase_required",
|
||||
connectionLogs,
|
||||
});
|
||||
} else if (
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
(err.message.includes("authentication") ||
|
||||
err.message.includes("All configured authentication methods failed"))
|
||||
) {
|
||||
@@ -1392,14 +1466,22 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
} else {
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none";
|
||||
resolvedCredentials.authType !== "none" &&
|
||||
resolvedCredentials.authType !== "tailscale" &&
|
||||
resolvedCredentials.authType !== "warpgate";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (resolvedCredentials.authType === "warpgate") {
|
||||
finish(prompts.map(() => ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
passwordPromptIndex !== -1
|
||||
) {
|
||||
if (responseSent) return;
|
||||
@@ -1477,20 +1559,22 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
);
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
useSocks5 &&
|
||||
(socks5Host ||
|
||||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
|
||||
resolvedUseSocks5 &&
|
||||
(resolvedSocks5Host ||
|
||||
(resolvedSocks5ProxyChain &&
|
||||
(resolvedSocks5ProxyChain as ProxyNode[]).length > 0))
|
||||
? {
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
|
||||
useSocks5: resolvedUseSocks5,
|
||||
socks5Host: resolvedSocks5Host,
|
||||
socks5Port: resolvedSocks5Port,
|
||||
socks5Username: resolvedSocks5Username,
|
||||
socks5Password: resolvedSocks5Password,
|
||||
socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[],
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasJumpHosts = jumpHosts && jumpHosts.length > 0 && userId;
|
||||
const hasJumpHosts =
|
||||
resolvedJumpHosts && resolvedJumpHosts.length > 0 && userId;
|
||||
|
||||
if (hasJumpHosts) {
|
||||
try {
|
||||
@@ -1507,11 +1591,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"jump",
|
||||
`Connecting via ${jumpHosts.length} jump host(s)`,
|
||||
`Connecting via ${resolvedJumpHosts.length} jump host(s)`,
|
||||
),
|
||||
);
|
||||
const jumpClient = await createJumpHostChain(
|
||||
jumpHosts,
|
||||
resolvedJumpHosts,
|
||||
userId,
|
||||
proxyConfig,
|
||||
);
|
||||
@@ -1535,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) => {
|
||||
if (forwardOutDone) return;
|
||||
forwardOutDone = true;
|
||||
clearTimeout(forwardOutTimeout);
|
||||
|
||||
if (err) {
|
||||
fileLogger.error("Failed to forward through jump host", err, {
|
||||
operation: "file_jump_forward",
|
||||
|
||||
@@ -21,6 +21,37 @@ interface VerificationResponse {
|
||||
}
|
||||
|
||||
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(
|
||||
hostId: number | null,
|
||||
ip: string,
|
||||
@@ -28,6 +59,9 @@ export class SSHHostKeyVerifier {
|
||||
ws: WebSocket | null,
|
||||
userId: string,
|
||||
isJumpHost: boolean = false,
|
||||
preloadedHost?: Awaited<
|
||||
ReturnType<typeof SSHHostKeyVerifier.preloadHostData>
|
||||
>,
|
||||
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
|
||||
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
|
||||
(async () => {
|
||||
@@ -52,9 +86,10 @@ export class SSHHostKeyVerifier {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await db.query.hosts.findFirst({
|
||||
where: eq(hosts.id, hostId),
|
||||
});
|
||||
const host =
|
||||
preloadedHost !== undefined
|
||||
? preloadedHost
|
||||
: await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) });
|
||||
|
||||
if (!host) {
|
||||
sshLogger.warn(
|
||||
@@ -141,13 +176,6 @@ export class SSHHostKeyVerifier {
|
||||
}
|
||||
|
||||
if (host.hostKeyFingerprint === fingerprint) {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
|
||||
sshLogger.info("Host key verified successfully", {
|
||||
operation: "host_key_verified",
|
||||
hostId,
|
||||
@@ -158,7 +186,18 @@ export class SSHHostKeyVerifier {
|
||||
userId,
|
||||
});
|
||||
|
||||
// Verify first, then update the timestamp asynchronously so the
|
||||
// DB write doesn't delay the SSH key exchange critical path.
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
createConnectionLog,
|
||||
} from "./server-stats-helpers.js";
|
||||
} from "./host-metrics-helpers.js";
|
||||
|
||||
describe("supportsMetrics", () => {
|
||||
it("supports plain ssh hosts", () => {
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../../types/host-metrics.js";
|
||||
|
||||
interface PrefStatsConfig {
|
||||
enabledWidgets?: string[];
|
||||
}
|
||||
|
||||
interface PrefHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
statsConfig?: unknown;
|
||||
}
|
||||
|
||||
type HostMetricsPreferencesRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
|
||||
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: "read" | "execute",
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.slots)) return null;
|
||||
const columns =
|
||||
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
|
||||
? Math.round(obj.columns)
|
||||
: 3;
|
||||
const slots = obj.slots
|
||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
||||
.map((s, i) => ({
|
||||
id: String(s.id),
|
||||
order: typeof s.order === "number" ? s.order : i,
|
||||
colSpan:
|
||||
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
|
||||
height:
|
||||
typeof s.height === "number" && s.height > 0
|
||||
? Math.round(s.height)
|
||||
: null,
|
||||
}))
|
||||
.filter((s) => s.id && s.id !== "undefined");
|
||||
return { slots, columns } as HostMetricsLayout;
|
||||
}
|
||||
|
||||
export function registerHostMetricsPreferencesRoutes(
|
||||
app: Express,
|
||||
{
|
||||
validateHostId,
|
||||
fetchHostById,
|
||||
parseStatsConfig,
|
||||
canAccessHost,
|
||||
}: HostMetricsPreferencesRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/preferences/{id}:
|
||||
* get:
|
||||
* summary: Get the Host Metrics layout for a host
|
||||
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The layout for this host.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
*/
|
||||
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT layout FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, hostId) as { layout: string } | undefined;
|
||||
|
||||
if (row?.layout) {
|
||||
try {
|
||||
const parsed = sanitizeLayout(JSON.parse(row.layout));
|
||||
if (parsed) return res.json({ layout: parsed });
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
const statsConfig = parseStatsConfig(host.statsConfig);
|
||||
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
|
||||
return res.json({ layout });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch host metrics preferences", {
|
||||
operation: "host_metrics_prefs_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch host metrics preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/preferences/{id}:
|
||||
* post:
|
||||
* summary: Save the Host Metrics layout for a host
|
||||
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* slots:
|
||||
* type: array
|
||||
* columns:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 400:
|
||||
* description: Invalid layout.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/preferences/:id",
|
||||
validateHostId,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "read"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(req.body);
|
||||
if (!layout) {
|
||||
return res.status(400).json({ error: "Invalid layout" });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
const existing = db.$client
|
||||
.prepare(
|
||||
"SELECT id FROM host_metrics_preferences WHERE user_id = ? AND host_id = ?",
|
||||
)
|
||||
.get(userId, hostId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE host_metrics_preferences SET layout = ?, updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(layoutJson, now, existing.id);
|
||||
} else {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO host_metrics_preferences (user_id, host_id, layout, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(userId, hostId, layoutJson, now, now);
|
||||
}
|
||||
|
||||
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
|
||||
// user owns. stats_config is plain JSON text (not an encrypted field).
|
||||
if (host.userId === userId) {
|
||||
try {
|
||||
const current = parseStatsConfig(host.statsConfig);
|
||||
const merged = {
|
||||
...current,
|
||||
enabledWidgets: deriveEnabledWidgets(layout.slots),
|
||||
};
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE ssh_data SET stats_config = ? WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.run(JSON.stringify(merged), hostId, userId);
|
||||
} catch (syncErr) {
|
||||
statsLogger.warn("Failed to sync enabledWidgets from layout", {
|
||||
operation: "host_metrics_prefs_sync_widgets",
|
||||
hostId,
|
||||
error:
|
||||
syncErr instanceof Error ? syncErr.message : String(syncErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("host_metrics_preferences_updated");
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save host metrics preferences", {
|
||||
operation: "host_metrics_prefs_save_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save host metrics preferences" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user