mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9de904dd4c | |||
| fd27a366d0 | |||
| eb49f197ca | |||
| 98195ec5c3 | |||
| 9c317251ca | |||
| 6194a58b1a | |||
| b1ec2bcd2b | |||
| 0354401640 | |||
| 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 @@
|
||||
custom: https://donate.termix.site/
|
||||
@@ -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@v7
|
||||
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@v7
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
name: Donation Goal Badge
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate donation goal SVG
|
||||
run: |
|
||||
set -e
|
||||
|
||||
EVM_ADDRESS="0x83eA1Db55cc6E34fCD11Da2b7849621af67b6E34"
|
||||
BTC_ADDRESS="bc1qrxc3vpnl6qhh9p8akjmjukcgmgmq852ua64h05"
|
||||
SOL_ADDRESS="T4BF5ioySVUjwaPNw4Sdu7oK8SXLxgQRcMaTQ6YJ2UJ"
|
||||
DOCS_SNAPSHOT_URL="https://raw.githubusercontent.com/Termix-SSH/Termix-Docs/main/static/donation-snapshot.json"
|
||||
MONTHLY_GOAL=750
|
||||
|
||||
# Fetch crypto prices
|
||||
PRICES=$(curl -sf "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,bitcoin,solana&vs_currencies=usd" || echo '{}')
|
||||
ETH_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethereum',{}).get('usd',0))" 2>/dev/null || echo 0)
|
||||
BTC_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('bitcoin',{}).get('usd',0))" 2>/dev/null || echo 0)
|
||||
SOL_PRICE=$(echo "$PRICES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('solana',{}).get('usd',0))" 2>/dev/null || echo 0)
|
||||
|
||||
# Fetch live balances
|
||||
ETH_RAW=$(curl -sf "https://eth.blockscout.com/api/v2/addresses/${EVM_ADDRESS}" || echo '{"coin_balance":"0"}')
|
||||
ETH_BAL=$(echo "$ETH_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(int(d.get('coin_balance','0')) / 1e18)" 2>/dev/null || echo 0)
|
||||
|
||||
BASE_RAW=$(curl -sf "https://base.blockscout.com/api/v2/addresses/${EVM_ADDRESS}" || echo '{"coin_balance":"0"}')
|
||||
BASE_BAL=$(echo "$BASE_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(int(d.get('coin_balance','0')) / 1e18)" 2>/dev/null || echo 0)
|
||||
|
||||
BTC_RAW=$(curl -sf "https://blockstream.info/api/address/${BTC_ADDRESS}" || echo '{"chain_stats":{"funded_txo_sum":0,"spent_txo_sum":0}}')
|
||||
BTC_BAL=$(echo "$BTC_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); s=d.get('chain_stats',{}); print((s.get('funded_txo_sum',0)-s.get('spent_txo_sum',0))/1e8)" 2>/dev/null || echo 0)
|
||||
|
||||
SOL_RAW=$(curl -sf -X POST "https://api.mainnet-beta.solana.com" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"${SOL_ADDRESS}\"]}" || echo '{"result":{"value":0}}')
|
||||
SOL_BAL=$(echo "$SOL_RAW" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('result',{}).get('value',0)/1e9)" 2>/dev/null || echo 0)
|
||||
|
||||
# Fetch snapshot baseline from docs repo
|
||||
SNAPSHOT=$(curl -sf "$DOCS_SNAPSHOT_URL" || echo '{"ethBal":0,"baseBal":0,"btcBal":0,"solBal":0}')
|
||||
SNAP_ETH=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_BASE=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('baseBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_BTC=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('btcBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_SOL=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('solBal',0))" 2>/dev/null || echo 0)
|
||||
|
||||
export ETH_BAL BASE_BAL BTC_BAL SOL_BAL SNAP_ETH SNAP_BASE SNAP_BTC SNAP_SOL ETH_PRICE BTC_PRICE SOL_PRICE MONTHLY_GOAL
|
||||
|
||||
python3 - <<'PYEOF'
|
||||
import os, math
|
||||
|
||||
eth_bal = float(os.environ.get('ETH_BAL', 0))
|
||||
base_bal = float(os.environ.get('BASE_BAL', 0))
|
||||
btc_bal = float(os.environ.get('BTC_BAL', 0))
|
||||
sol_bal = float(os.environ.get('SOL_BAL', 0))
|
||||
snap_eth = float(os.environ.get('SNAP_ETH', 0))
|
||||
snap_base = float(os.environ.get('SNAP_BASE', 0))
|
||||
snap_btc = float(os.environ.get('SNAP_BTC', 0))
|
||||
snap_sol = float(os.environ.get('SNAP_SOL', 0))
|
||||
eth_price = float(os.environ.get('ETH_PRICE', 0))
|
||||
btc_price = float(os.environ.get('BTC_PRICE', 0))
|
||||
sol_price = float(os.environ.get('SOL_PRICE', 0))
|
||||
goal = float(os.environ.get('MONTHLY_GOAL', 750))
|
||||
|
||||
delta_eth = max(0, eth_bal - snap_eth)
|
||||
delta_base = max(0, base_bal - snap_base)
|
||||
delta_btc = max(0, btc_bal - snap_btc)
|
||||
delta_sol = max(0, sol_bal - snap_sol)
|
||||
|
||||
total = delta_eth * eth_price + delta_base * eth_price + delta_btc * btc_price + delta_sol * sol_price
|
||||
pct = min(total / goal * 100, 100) if goal > 0 else 0
|
||||
|
||||
if total < 1:
|
||||
display = '< $1'
|
||||
else:
|
||||
display = f'${math.floor(total):,}'
|
||||
|
||||
goal_display = f'${int(goal):,}'
|
||||
|
||||
# SVG dimensions
|
||||
W = 400
|
||||
BAR_W = 360
|
||||
BAR_X = 20
|
||||
BAR_H = 8
|
||||
FILL_W = round(BAR_W * pct / 100)
|
||||
|
||||
label = f'Monthly Donation Goal {display} / {goal_display}'
|
||||
pct_label = f'{round(pct)}% of monthly goal' if pct < 100 else 'Goal reached this month. Thank you!'
|
||||
|
||||
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="52" role="img" aria-label="Monthly donation goal">
|
||||
<title>Monthly donation goal</title>
|
||||
<rect width="{W}" height="52" rx="6" fill="#0c0d0b"/>
|
||||
<text x="{W//2}" y="13" font-family="sans-serif" font-size="11" fill="#F39044" text-anchor="middle">{label}</text>
|
||||
<rect x="{BAR_X}" y="20" width="{BAR_W}" height="{BAR_H}" rx="4" fill="#F3904433"/>
|
||||
<rect x="{BAR_X}" y="20" width="{FILL_W}" height="{BAR_H}" rx="4" fill="#F39044"/>
|
||||
<text x="{W//2}" y="44" font-family="sans-serif" font-size="10" fill="#F3904499" text-anchor="middle">{pct_label}</text>
|
||||
</svg>'''
|
||||
|
||||
with open('repo-images/donation-goal.svg', 'w') as f:
|
||||
f.write(svg)
|
||||
print(f'SVG written: {display} / {goal_display} ({round(pct)}%)')
|
||||
PYEOF
|
||||
- name: Commit SVG
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add repo-images/donation-goal.svg
|
||||
if git diff --cached --quiet; then
|
||||
exit 0
|
||||
fi
|
||||
# Amend the previous bot commit if it exists, otherwise create a new one
|
||||
LAST_AUTHOR=$(git log -1 --format='%ae')
|
||||
if [ "$LAST_AUTHOR" = "github-actions[bot]@users.noreply.github.com" ]; then
|
||||
git commit --amend --no-edit
|
||||
git push --force-with-lease
|
||||
else
|
||||
git commit -m "chore: update donation goal badge"
|
||||
git push
|
||||
fi
|
||||
+125
-57
@@ -52,12 +52,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
|
||||
- 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@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "npm"
|
||||
|
||||
+416
-23
@@ -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@v7
|
||||
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,251 @@ 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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
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: ${{ secrets.GHCR_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,28 +331,31 @@ jobs:
|
||||
--notes RELEASE_NOTES.md > RELEASE_BODY.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
if: ${{ inputs.mode != 'Overwrite release' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
||||
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 +364,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@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
@@ -146,5 +397,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@v7
|
||||
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@v7
|
||||
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@v7
|
||||
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.1"
|
||||
sha256 "c71209bf0bde9eefa5aeefbfc2b1db3af4ca3546e5f2ef09d233a6d3d1719150"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix is free and open source. If you find it useful, consider [donating](https://donate.termix.site/) to help cover server costs and development time.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="./repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Manage files directly on remote servers with support for viewing and editing cod
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker Management:**
|
||||
Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
|
||||
**Docker and Podman Management:**
|
||||
Start, stop, pause, remove containers. View container stats. Control containers using a docker exec terminal. Supports both Docker and Podman as the container runtime. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
|
||||
|
||||
</td>
|
||||
<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. Includes time-series history graphs and threshold-based alerts with ntfy and webhook support.
|
||||
|
||||
</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), 2FA (TOTP), and passkey (WebAuthn) 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">
|
||||
|
||||
**Serial Connections:**
|
||||
Connect to serial devices (routers, switches, microcontrollers, etc.) directly from the browser or desktop app. Configure baud rate, data bits, stop bits, and parity. Uses the Web Serial API in supported browsers or a native backend in the Electron app.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Alerts:**
|
||||
Set threshold-based alert rules on host metrics (CPU, memory, disk, etc.) and get notified via ntfy or webhooks when they fire. View firing and resolved alerts in a history log.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Homepage:**
|
||||
A fully customizable homepage with a drag-and-drop widget grid. Add widgets for host status, service links, clocks, notes, RSS feeds, weather, Docker containers, host metrics charts, embedded terminals, iframes, and more.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,9 @@ 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, SSH agent forwarding, Bitwarden SSH agent, HashiCorp Vault SSH signing, and more.
|
||||
- **Termix ID** - A sshid.io equivalent built into Termix. Claim a handle, publish your public SSH keys at a resolver URL, and use a built-in CA to issue SSH certificates.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -254,6 +291,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time.
|
||||
|
||||
[Donate](https://donate.termix.site/)
|
||||
|
||||
<br />
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
@@ -296,6 +341,10 @@ networks:
|
||||
<td><img src="./repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="./repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="./repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Some videos and images may be out of date or may not perfectly showcase features.</sub>
|
||||
@@ -306,7 +355,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 />
|
||||
|
||||
|
||||
+64
-38
@@ -1,55 +1,81 @@
|
||||
<!-- SUMMARY -->
|
||||
|
||||
Bug fixes and new features including host-to-host file transfer, OIDC improvements, UI/UX updates, and numerous stability and security patches.
|
||||
Major new features including serial connections, Tailscale/WireGuard support, HashiCorp Vault SSH auth, Bitwarden SSH agent, WebAuthn passkeys, Podman support, a new grid-based dashboard, host metrics history with alerting, and much more.
|
||||
|
||||
<!-- /SUMMARY -->
|
||||
|
||||
<!-- YOUTUBE -->
|
||||
|
||||
https://youtu.be/At8iDk6-Q_s
|
||||
https://youtu.be/c3UD4q2jW_8
|
||||
|
||||
<!-- /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
|
||||
- Termix ID with a public handle, hosted public key resolver, and built-in CA for issuing SSH certificates
|
||||
- Serial connections support
|
||||
- Tailscale and WireGuard VPN host integration with status detection
|
||||
- HashiCorp Vault SSH signer authentication
|
||||
- Bitwarden SSH agent integration
|
||||
- WebAuthn passkey authentication
|
||||
- Podman container runtime support alongside Docker
|
||||
- SSH agent forwarding support across all SSH features
|
||||
- New grid and widget-based dashboard homepage
|
||||
- Grafana-style server stats history graphs
|
||||
- Alert system with ntfy and webhook notification support
|
||||
- Host temperature metrics card
|
||||
- App fullscreen mode
|
||||
- External editor support for file manager (desktop app)
|
||||
- Safe host sharing export
|
||||
- SSH credential password fallback for key-based auth
|
||||
- Open all sessions in a folder at once
|
||||
- Custom terminal theme color support
|
||||
- Custom tunnel endpoints configuration
|
||||
- GUACD_URL environment variable support
|
||||
- App rail hover expansion setting
|
||||
- Terminal font zoom with mouse wheel
|
||||
- File manager terminals promoted to full tabs
|
||||
- Donate button on dashboard
|
||||
- PuTTY PPK SSH key support
|
||||
- Confirmation dialog when closing active host connections
|
||||
- Confirmation prompt before opening large files in the editor
|
||||
- Cross-host file manager clipboard
|
||||
- Prioritize host results in command palette search
|
||||
- Retry autostart tunnel host fetches on failure
|
||||
<!-- /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
|
||||
- SSH port connection bug
|
||||
- VNC required argument handshake failure
|
||||
- Jump host SOCKS5 proxy selection using wrong proxy
|
||||
- Tunnel endpoint resolution failing in some configurations
|
||||
- Direct tunnel skipping endpoint credential validation incorrectly
|
||||
- Dashboard host routing ignoring protocol settings
|
||||
- Dashboard service link creation broken
|
||||
- File manager uploads failing with 400 error and missing schema migrations on upgrade
|
||||
- Large file manager uploads not chunked (chunked for files >=1.5GB)
|
||||
- File uploads over 100MB failing due to ArrayBuffer browser limit
|
||||
- File path case not preserved in file manager UI
|
||||
- File downloads unreliable in the desktop app
|
||||
- Tmux detection path handling incorrect
|
||||
- Host metrics startup polling incorrect
|
||||
- TUI terminal output highlighting incorrect
|
||||
- Runtime base path for auth callbacks incorrect
|
||||
- Windows app icon unstable
|
||||
- SSH heading syntax highlighting broken
|
||||
- Terminal link dialog layering issue
|
||||
- Electron OIDC browser authentication failures
|
||||
- Proxmox import auth fallback not working
|
||||
- OIDC role credential shares not synced for OIDC users
|
||||
- RDP connections requiring credentials when none are needed
|
||||
- VNC authentication settings not persisted
|
||||
- Guacamole unicode token corruption
|
||||
- Guacamole websocket base path incorrect
|
||||
- Guacamole disconnect during startup crash
|
||||
- Host metrics starting for non-SSH hosts
|
||||
- Sidebar host hover causing layout shift
|
||||
- Alert UI incorrectly applying Termix CSS and alert system failing to load
|
||||
- Translation key incorrect for nav close action
|
||||
- PUID HTML ownership in Docker entrypoint
|
||||
<!-- /BUG_FIXES -->
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "dev-2.5.0"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!!build",
|
||||
"!!coverage",
|
||||
"!!dist",
|
||||
"!!dist-ssr",
|
||||
"!!release",
|
||||
"!!node_modules",
|
||||
"!!src/mcp-server/node_modules",
|
||||
"!!db",
|
||||
"!!.env",
|
||||
"!!**/*.min.js",
|
||||
"!!**/*.min.css",
|
||||
"!!openapi.json"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all",
|
||||
"arrowParentheses": "always"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ 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 && \
|
||||
@@ -78,7 +78,7 @@ COPY --chown=node:node package.json ./
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
|
||||
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 30007 30008 30009 30010 30011 30012
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://localhost:30001/health || exit 1
|
||||
|
||||
@@ -14,7 +14,7 @@ if [ "$(id -u)" = "0" ]; then
|
||||
groupmod -o -g "$PGID" node 2>/dev/null || true
|
||||
usermod -o -u "$PUID" node 2>/dev/null || true
|
||||
|
||||
chown -R node:node /app/data /app/uploads /tmp/nginx 2>/dev/null || true
|
||||
chown -R node:node /app/data /app/uploads /app/html /tmp/nginx 2>/dev/null || true
|
||||
|
||||
echo "User node is now UID: $PUID, GID: $PGID"
|
||||
|
||||
@@ -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
|
||||
@@ -167,4 +167,4 @@ node dist/backend/backend/starter.js
|
||||
|
||||
echo "All services started"
|
||||
|
||||
tail -f /dev/null
|
||||
tail -f /dev/null
|
||||
|
||||
+178
-36
@@ -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,34 @@ 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 ~ ^/alert-rules(/.*)?$ {
|
||||
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 ~ ^/notification-channels(/.*)?$ {
|
||||
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 ~ ^/alert-firings(/.*)?$ {
|
||||
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 ~ ^/rbac(/.*)?$ {
|
||||
@@ -153,7 +192,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 +201,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;
|
||||
@@ -170,6 +209,24 @@ http {
|
||||
}
|
||||
|
||||
location ~ ^/snippets(/.*)?$ {
|
||||
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 ~ ^/vault(/.*)?$ {
|
||||
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 ~ ^/termix-id(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
@@ -184,7 +241,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 +259,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 +268,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 +277,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 +289,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 +308,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 +324,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 +336,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 +375,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 +432,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 +452,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 +461,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 +472,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 +485,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 +494,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 +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;
|
||||
}
|
||||
|
||||
location /host/file_manager/sudo-password {
|
||||
@@ -428,7 +512,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 +526,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 +547,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 +563,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 +572,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 +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 ~ ^/metrics(/.*)?$ {
|
||||
@@ -506,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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
@@ -519,7 +603,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 +625,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 +634,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 +643,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 ~ ^/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 ~ ^/homepage(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30012;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
@@ -569,7 +675,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,19 +689,55 @@ 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;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /serial/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30011/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
|
||||
+190
-36
@@ -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,34 @@ 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 ~ ^/alert-rules(/.*)?$ {
|
||||
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 ~ ^/notification-channels(/.*)?$ {
|
||||
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 ~ ^/alert-firings(/.*)?$ {
|
||||
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 ~ ^/rbac(/.*)?$ {
|
||||
@@ -142,7 +181,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 +190,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;
|
||||
@@ -159,6 +198,24 @@ http {
|
||||
}
|
||||
|
||||
location ~ ^/snippets(/.*)?$ {
|
||||
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 ~ ^/vault(/.*)?$ {
|
||||
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 ~ ^/termix-id(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
@@ -167,13 +224,34 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
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 +260,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 +269,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 +278,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 +290,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 +309,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 +325,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 +337,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 +376,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 +433,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 +453,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 +462,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 +473,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 +486,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 +495,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 +504,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 +513,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 +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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
@@ -452,7 +548,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 +564,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 +573,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 +582,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 +591,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 +604,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 +626,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 +635,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 +644,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 ~ ^/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 ~ ^/homepage(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30012;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ^~ /docker/console/ {
|
||||
@@ -558,7 +676,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,19 +690,55 @@ 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;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /serial/websocket/ {
|
||||
proxy_pass http://127.0.0.1:30011/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 10s;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /app/html;
|
||||
|
||||
@@ -28,11 +28,8 @@
|
||||
"!dist/icon-mac.png",
|
||||
"!public/icon-mac.png",
|
||||
"!dist/icon.ico",
|
||||
"!public/icon.ico",
|
||||
"!dist/icon.icns",
|
||||
"!public/icon.icns",
|
||||
"!dist/icons/**/*",
|
||||
"!public/icons/**/*"
|
||||
"!public/icon.icns"
|
||||
],
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs",
|
||||
@@ -61,6 +58,8 @@
|
||||
"artifactName": "termix_windows_${arch}_nsis.${ext}",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"installerIcon": "public/icon.ico",
|
||||
"uninstallerIcon": "public/icon.ico",
|
||||
"shortcutName": "Termix",
|
||||
"uninstallDisplayName": "Termix"
|
||||
},
|
||||
@@ -130,6 +129,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",
|
||||
|
||||
+330
-17
@@ -9,6 +9,7 @@ const {
|
||||
safeStorage,
|
||||
Tray,
|
||||
clipboard,
|
||||
nativeImage,
|
||||
} = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
@@ -17,9 +18,35 @@ const https = require("https");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const { URL } = require("url");
|
||||
const { fork } = require("child_process");
|
||||
const { fork, spawn } = 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 +503,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()) {
|
||||
@@ -493,15 +529,117 @@ let backendProcess = null;
|
||||
let backendStartFailed = false;
|
||||
let tray = null;
|
||||
let isQuitting = false;
|
||||
const tempFiles = new Map();
|
||||
const externalEditorSessions = new Map();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
||||
const windowsAppUserModelId = "com.karmaa.termix";
|
||||
const electronCacheBuildPath = path.join(
|
||||
app.getPath("userData"),
|
||||
"client-cache-build.json",
|
||||
);
|
||||
const termixSessionPartition = "persist:termix";
|
||||
|
||||
function getTempRoot() {
|
||||
const tempRoot = path.join(app.getPath("temp"), "termix");
|
||||
fs.mkdirSync(tempRoot, { recursive: true });
|
||||
return tempRoot;
|
||||
}
|
||||
|
||||
function sanitizeFileName(fileName) {
|
||||
const baseName = path.basename(String(fileName || "file"));
|
||||
return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
|
||||
}
|
||||
|
||||
function decodeFileContent(content, encoding) {
|
||||
if (encoding === "base64") {
|
||||
return Buffer.from(String(content || ""), "base64");
|
||||
}
|
||||
return Buffer.from(String(content || ""), "utf8");
|
||||
}
|
||||
|
||||
function createManagedTempFile(fileName, content, encoding = "utf8") {
|
||||
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
||||
const filePath = path.join(tempDir, sanitizeFileName(fileName));
|
||||
fs.writeFileSync(filePath, decodeFileContent(content, encoding));
|
||||
tempFiles.set(tempId, { path: filePath, dir: tempDir });
|
||||
return { tempId, path: filePath };
|
||||
}
|
||||
|
||||
function cleanupManagedTempFile(tempId) {
|
||||
const temp = tempFiles.get(tempId);
|
||||
if (!temp) return;
|
||||
try {
|
||||
fs.rmSync(temp.dir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
logToFile("Failed to clean up temporary file:", error.message);
|
||||
}
|
||||
tempFiles.delete(tempId);
|
||||
}
|
||||
|
||||
function closeExternalEditorSession(editId) {
|
||||
const session = externalEditorSessions.get(editId);
|
||||
if (!session) return;
|
||||
if (session.timer) clearTimeout(session.timer);
|
||||
try {
|
||||
session.watcher.close();
|
||||
} catch {
|
||||
// watcher may already be closed
|
||||
}
|
||||
externalEditorSessions.delete(editId);
|
||||
cleanupManagedTempFile(editId);
|
||||
}
|
||||
|
||||
function notifyExternalEditorSaved(editId) {
|
||||
const session = externalEditorSessions.get(editId);
|
||||
if (!session || !mainWindow || mainWindow.isDestroyed()) return;
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(session.path);
|
||||
if (stat.mtimeMs === session.lastMtimeMs) return;
|
||||
session.lastMtimeMs = stat.mtimeMs;
|
||||
|
||||
const content = fs.readFileSync(session.path, "utf8");
|
||||
mainWindow.webContents.send("external-editor-saved", {
|
||||
editId,
|
||||
content,
|
||||
encoding: "utf8",
|
||||
path: session.path,
|
||||
});
|
||||
} catch (error) {
|
||||
logToFile("Failed to read external editor file:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openPathWithEditor(filePath, editorPath) {
|
||||
if (!editorPath) {
|
||||
return shell.openPath(filePath);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const child = spawn(editorPath, [filePath], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(error.message);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.unref();
|
||||
resolve("");
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
app.on(
|
||||
"certificate-error",
|
||||
(event, _webContents, url, error, certificate, callback) => {
|
||||
@@ -655,9 +793,11 @@ function getBackendPaths() {
|
||||
backendCwd: backendDir,
|
||||
};
|
||||
}
|
||||
// fork() does not go through Electron's asar redirector — use the unpacked path
|
||||
// fork() does not go through Electron's asar redirector — use the unpacked path.
|
||||
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
||||
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
||||
const unpackedRoot = appRoot.replace(
|
||||
/app\.asar(?!\.unpacked)/,
|
||||
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
|
||||
@@ -815,7 +955,13 @@ function createTray() {
|
||||
// use the unpacked path so the OS sees a real file.
|
||||
const publicRoot = isDev
|
||||
? path.join(appRoot, "public")
|
||||
: path.join(appRoot.replace("app.asar", "app.asar.unpacked"), "public");
|
||||
: path.join(
|
||||
appRoot.replace(
|
||||
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
||||
"app.asar.unpacked",
|
||||
),
|
||||
"public",
|
||||
);
|
||||
|
||||
let trayIcon;
|
||||
if (process.platform === "darwin") {
|
||||
@@ -885,7 +1031,11 @@ function createWindow() {
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
icon: path.join(appRoot, "public", "icon.png"),
|
||||
icon: path.join(
|
||||
appRoot,
|
||||
"public",
|
||||
process.platform === "win32" ? "icon.ico" : "icon.png",
|
||||
),
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
@@ -1196,9 +1346,11 @@ ipcMain.handle(
|
||||
async (_event, authUrl, callbackPort) => {
|
||||
const http = require("http");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
let timeout;
|
||||
let settled = false;
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${callbackPort}`);
|
||||
const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
|
||||
if (url.pathname === "/oidc-callback") {
|
||||
const success = url.searchParams.get("success");
|
||||
const error = url.searchParams.get("error");
|
||||
@@ -1209,27 +1361,54 @@ ipcMain.handle(
|
||||
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
|
||||
);
|
||||
|
||||
server.close();
|
||||
if (success === "true") {
|
||||
resolve({ success: true, token });
|
||||
finish({ success: true, token });
|
||||
} else {
|
||||
resolve({
|
||||
finish({
|
||||
success: false,
|
||||
error: error || "Authentication failed",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// Server may not have started yet.
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const fail = (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
};
|
||||
|
||||
server.once("error", fail);
|
||||
|
||||
server.listen(callbackPort, "127.0.0.1", async () => {
|
||||
try {
|
||||
await shell.openExternal(authUrl);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(callbackPort, "127.0.0.1", () => {
|
||||
shell.openExternal(authUrl);
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(
|
||||
timeout = setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
reject(new Error("OIDC authentication timed out"));
|
||||
fail(new Error("OIDC authentication timed out"));
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
@@ -2465,6 +2644,131 @@ ipcMain.handle("clipboard-write-text", (_event, text) => {
|
||||
|
||||
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
|
||||
|
||||
ipcMain.handle("show-save-dialog", async (_event, options) => {
|
||||
return dialog.showSaveDialog(mainWindow, options || {});
|
||||
});
|
||||
|
||||
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
||||
return dialog.showOpenDialog(mainWindow, options || {});
|
||||
});
|
||||
|
||||
ipcMain.handle("create-temp-file", async (_event, fileData) => {
|
||||
try {
|
||||
const result = createManagedTempFile(
|
||||
fileData?.fileName,
|
||||
fileData?.content,
|
||||
fileData?.encoding,
|
||||
);
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
|
||||
try {
|
||||
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
||||
const folderPath = path.join(
|
||||
tempDir,
|
||||
sanitizeFileName(folderData?.folderName || "files"),
|
||||
);
|
||||
fs.mkdirSync(folderPath, { recursive: true });
|
||||
|
||||
for (const file of folderData?.files || []) {
|
||||
const relativePath = String(file.relativePath || "")
|
||||
.split(/[\\/]+/)
|
||||
.map(sanitizeFileName)
|
||||
.filter(Boolean)
|
||||
.join(path.sep);
|
||||
if (!relativePath) continue;
|
||||
const targetPath = path.join(folderPath, relativePath);
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
targetPath,
|
||||
decodeFileContent(file.content, file.encoding),
|
||||
);
|
||||
}
|
||||
|
||||
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
|
||||
return { success: true, tempId, path: folderPath };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
|
||||
try {
|
||||
const temp = tempFiles.get(dragData?.tempId);
|
||||
if (!temp) return { success: false, error: "Temporary file not found" };
|
||||
|
||||
event.sender.startDrag({
|
||||
file: temp.path,
|
||||
icon: nativeImage.createEmpty(),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
||||
try {
|
||||
cleanupManagedTempFile(tempId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
||||
try {
|
||||
const result = createManagedTempFile(
|
||||
fileData?.fileName,
|
||||
fileData?.content,
|
||||
fileData?.encoding,
|
||||
);
|
||||
const editId = result.tempId;
|
||||
const stat = fs.statSync(result.path);
|
||||
const watcher = fs.watch(result.path, { persistent: false }, () => {
|
||||
const session = externalEditorSessions.get(editId);
|
||||
if (!session) return;
|
||||
if (session.timer) clearTimeout(session.timer);
|
||||
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
|
||||
});
|
||||
|
||||
externalEditorSessions.set(editId, {
|
||||
path: result.path,
|
||||
watcher,
|
||||
timer: null,
|
||||
lastMtimeMs: stat.mtimeMs,
|
||||
});
|
||||
|
||||
const editorPath =
|
||||
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
||||
? fileData.editorPath.trim()
|
||||
: null;
|
||||
const openError = await openPathWithEditor(result.path, editorPath);
|
||||
if (openError) {
|
||||
closeExternalEditorSession(editId);
|
||||
return { success: false, error: openError };
|
||||
}
|
||||
|
||||
return { success: true, editId, path: result.path };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("close-external-editor", (_event, editId) => {
|
||||
try {
|
||||
closeExternalEditorSession(editId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
try {
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
@@ -2645,6 +2949,9 @@ app.whenReady().then(async () => {
|
||||
"arch:",
|
||||
process.arch,
|
||||
);
|
||||
if (process.platform === "win32") {
|
||||
app.setAppUserModelId(windowsAppUserModelId);
|
||||
}
|
||||
createMenu();
|
||||
await clearElectronClientCacheIfBuildChanged();
|
||||
await clearElectronJwtCookiesAtStartup();
|
||||
@@ -2684,6 +2991,12 @@ app.on("before-quit", () => {
|
||||
|
||||
app.on("will-quit", () => {
|
||||
console.log("App will quit...");
|
||||
for (const editId of externalEditorSessions.keys()) {
|
||||
closeExternalEditorSession(editId);
|
||||
}
|
||||
for (const tempId of tempFiles.keys()) {
|
||||
cleanupManagedTempFile(tempId);
|
||||
}
|
||||
stopAllC2STunnels();
|
||||
stopBackendServer();
|
||||
});
|
||||
|
||||
@@ -46,6 +46,26 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
oidcSystemBrowserAuth: (authUrl, callbackPort) =>
|
||||
ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort),
|
||||
|
||||
openExternalEditor: (fileData) =>
|
||||
ipcRenderer.invoke("open-external-editor", fileData),
|
||||
closeExternalEditor: (editId) =>
|
||||
ipcRenderer.invoke("close-external-editor", editId),
|
||||
onExternalEditorSaved: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("external-editor-saved", listener);
|
||||
return () => ipcRenderer.removeListener("external-editor-saved", listener);
|
||||
},
|
||||
|
||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||
createTempFile: (fileData) =>
|
||||
ipcRenderer.invoke("create-temp-file", fileData),
|
||||
createTempFolder: (folderData) =>
|
||||
ipcRenderer.invoke("create-temp-folder", folderData),
|
||||
startDragToDesktop: (dragData) =>
|
||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
});
|
||||
|
||||
|
||||
Generated
+2766
-2259
File diff suppressed because it is too large
Load Diff
+65
-54
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.3.2",
|
||||
"version": "2.5.0",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -12,6 +12,8 @@
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"biome:check": "biome check biome.json package.json",
|
||||
"biome:fix": "biome check --write biome.json package.json",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "eslint .",
|
||||
@@ -31,7 +33,7 @@
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
|
||||
"electron:patch-builder": "node scripts/patch-app-builder-lib.cjs",
|
||||
"electron:rebuild": "electron-rebuild -f -w better-sqlite3",
|
||||
"electron:rebuild": "electron-rebuild -f -w better-sqlite3 -w serialport",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --dir",
|
||||
"build:win-installer": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --publish=never",
|
||||
"build:linux-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux --dir",
|
||||
@@ -41,10 +43,13 @@
|
||||
"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",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.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,50 +58,56 @@
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"jose": "^6.2.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^5.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapjs": "^3.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"multer": "^2.1.1",
|
||||
"nanoid": "^5.1.9",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^5.1.15",
|
||||
"qrcode": "^1.5.4",
|
||||
"serialport": "^13.0.0",
|
||||
"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",
|
||||
"@biomejs/biome": "2.5.1",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@codemirror/view": "^6.43.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",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@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",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@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.3.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -109,9 +120,9 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^26.0.0",
|
||||
"@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",
|
||||
@@ -120,8 +131,8 @@
|
||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/ui": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
@@ -130,29 +141,29 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"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",
|
||||
"eslint": "^9.0.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"cytoscape": "^3.34.0",
|
||||
"electron": "^42.4.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"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",
|
||||
"prettier": "3.8.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lucide-react": "^1.20.0",
|
||||
"prettier": "3.8.4",
|
||||
"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,16 +172,16 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"sharp": "^0.35.2",
|
||||
"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"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# Host-to-host file transfer
|
||||
|
||||
This document describes the host-to-host copy/move feature. It is intended for operators and contributors. Direct host-to-host routing via SSH tunnels is **not implemented**; that is documented under [Future work](#future-work-direct-routing-via-tunnels).
|
||||
|
||||
## Overview
|
||||
|
||||
Host-to-host transfer copies or moves files from one SSH host to another through the **Termix server** as a relay. The UI lives in **File Manager**: right-click files or folders and choose **Copy to host…** or **Move to host…**.
|
||||
|
||||
Compared to copying via your laptop (`scp -3` or `ssh one 'cat …' | ssh two 'cat …'`), Termix keeps the job on the server so transfers continue if you close the browser, and you get integrated progress, cancel, retry, and cleanup.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Browser_UI]
|
||||
Termix[Termix_server]
|
||||
Src[Source_host]
|
||||
Dst[Destination_host]
|
||||
|
||||
Browser -->|start_transfer_API| Termix
|
||||
Termix -->|dedicated_SSH_xfer_src| Src
|
||||
Termix -->|dedicated_SSH_xfer_dst| Dst
|
||||
Termix -->|SFTP_read_then_write| Termix
|
||||
```
|
||||
|
||||
Data for remote-to-remote paths **always passes through the Termix process** (pipelined SFTP buffers, or a tar archive stream). There is no source→destination SSH tunnel for file bytes today.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Two hosts** with File Manager enabled, saved in Host Manager.
|
||||
2. **Both hosts reachable from the Termix server** on SSH (each host’s jump hosts and SOCKS5 proxy chain apply to the **Termix→host** connection, not host→host).
|
||||
3. **File Manager open** on the source host (browse session connected). The destination host should show **Ready** in the transfer dialog; if it shows authentication required, open File Manager on that host once first.
|
||||
4. For **multi-file or folder** transfers, pick a **destination directory** (not a file path).
|
||||
|
||||
The dialog shows a reminder: _“Both hosts must be reachable from the Termix server. Direct host-to-host routing is not supported.”_
|
||||
|
||||
## Using the UI
|
||||
|
||||
### Start a transfer
|
||||
|
||||
1. Open File Manager on the **source** host.
|
||||
2. Select one or more files or folders.
|
||||
3. Right-click → **Copy to host…** or **Move to host…** (sidebar tree supports the same context menu).
|
||||
4. In the dialog:
|
||||
- Choose **destination host** (other connected file-manager hosts).
|
||||
- Set **destination path** (type a path or use **Browse destination folders**).
|
||||
- Optionally pick a **recent destination** (collapsed by default).
|
||||
- For multi-item transfers, choose **transfer method** (Auto / Tar archive / Per-file SFTP); a preview explains what Auto will pick.
|
||||
- Pin folders with **Add to shortcuts** (per-destination host; uses normal file-manager shortcuts, not a separate favourites list).
|
||||
5. Confirm **Copy** or **Move**.
|
||||
|
||||
### During transfer
|
||||
|
||||
- A **progress toast** shows phase (compressing, transferring, extracting), bytes, speed, and **Cancel**.
|
||||
- **Transfer monitor** (desktop, when authenticated) picks up active transfers started in another tab or window.
|
||||
- Large **single files** use segmented SFTP (256 MiB segments) with **2 parallel lanes** by default (separate dedicated SSH session pairs per lane). The toast can show total speed and lane count when multiple lanes are active.
|
||||
|
||||
### After completion or failure
|
||||
|
||||
| Outcome | What happens |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Success** | Toast success; destination path saved to **recent destinations** for that source host; file manager can refresh. |
|
||||
| **Partial** | Some paths failed; toast lists failed paths; source may be kept on move. |
|
||||
| **Error** | Toast with **Retry** when partial data on destination allows resume. |
|
||||
| **Cancelled** | Toast with optional **Clean up destination** to remove partial files. |
|
||||
|
||||
**Move** deletes source files only after a successful full transfer (same as copy, then source delete). Cancelled or partial moves may leave files on both sides.
|
||||
|
||||
### Metrics
|
||||
|
||||
On success, expanded timing details can include: prepare destination, compress (tar), per-hop throughput (source→server, server→dest), extract, source delete, total duration.
|
||||
|
||||
## Transfer methods
|
||||
|
||||
| Method | When used | Behavior |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Stream (single file)** | Exactly one file, copy or move | Pipelined SFTP read on source → write on destination; segmented above 32 MiB; parallel lanes for throughput. |
|
||||
| **Tar archive** | Multi-file/folder when Auto or user selects Tar, and both sides are Unix with `tar` | `tar -czf` on source → one archive streamed through Termix → `tar -xzf` on destination. |
|
||||
| **Per-file SFTP** | Windows involved, tar unavailable, or Auto chooses it | Each file copied sequentially over SFTP through Termix. |
|
||||
|
||||
**Auto** heuristics (see `src/backend/ssh/transfer-routing.ts`) consider file count, total size, largest file, and compressibility (e.g. many small files → tar; large incompressible sets → per-file SFTP).
|
||||
|
||||
**Method preview** is locked for the current source path set until you change Auto/Tar/Per-file preference, so changing only the destination host does not re-scan the source.
|
||||
|
||||
## Architecture (implementation)
|
||||
|
||||
| Component | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts) | Transfer engine: sessions, SFTP pipeline, tar path, retry, cancel, progress. |
|
||||
| [`src/backend/ssh/file-manager.ts`](../src/backend/ssh/file-manager.ts) | HTTP routes, `openDedicatedTransferSession`, jump/SOCKS connect. |
|
||||
| [`src/backend/ssh/transfer-routing.ts`](../src/backend/ssh/transfer-routing.ts) | Tar vs per-file SFTP selection. |
|
||||
| [`src/backend/ssh/transfer-paths.ts`](../src/backend/ssh/transfer-paths.ts) | Path normalization (Unix/Windows). |
|
||||
| [`src/ui/.../TransferToHostDialog.tsx`](../src/ui/desktop/apps/features/file-manager/components/TransferToHostDialog.tsx) | Transfer dialog. |
|
||||
| [`src/ui/.../transferProgressMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/transferProgressMonitor.tsx) | Toasts, cancel, retry, cleanup. |
|
||||
| [`src/ui/.../TransferMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/TransferMonitor.tsx) | Global active-transfer polling. |
|
||||
|
||||
**Sessions:** Browse sessions identify hosts. Each transfer opens **dedicated** SSH sessions (`xfer:{transferId}:src` / `:dst`) so browsing and transfers do not share channels. Parallel lanes add `xfer:{transferId}:src:pN` / `:dst:pN`.
|
||||
|
||||
**Special case:** If the destination host is the **same machine as Termix** (local SSH endpoint), writes use the local filesystem via `fastGet` instead of dest SFTP; data still originates from the remote source through Termix.
|
||||
|
||||
**Persistence:** Recent destinations are stored in `transfer_recent` (per user, per source host). Folder shortcuts use `file_manager_shortcuts` on the destination host.
|
||||
|
||||
## HTTP API (file manager service)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST /ssh/file_manager/ssh/transferMethodPreview` | Scan source; return resolved tar vs item_sftp and reason. |
|
||||
| `POST /ssh/file_manager/ssh/transferToHost` | Start transfer; body includes `sourceSessionId`, `destSessionId`, `sourcePaths`, `destPath`, `move`, `methodPreference`, optional `parallelSegmentCount` (1–8, default 2). |
|
||||
| `GET /ssh/file_manager/ssh/transferStatus/:transferId` | Poll progress. |
|
||||
| `GET /ssh/file_manager/ssh/activeTransfers` | List running transfers for user. |
|
||||
| `POST /ssh/file_manager/ssh/transferCancel/:transferId` | Request cancel. |
|
||||
| `POST /ssh/file_manager/ssh/transferCleanup/:transferId` | Remove partial destination artifacts after cancel/failure. |
|
||||
| `POST /ssh/file_manager/ssh/transferRetry/:transferId` | Retry with same snapshot (resume when possible). |
|
||||
|
||||
Database (main API): `GET/POST /host/transfer/recent` for recent destinations.
|
||||
|
||||
## Reliability features
|
||||
|
||||
- **Resume:** Destination file size is probed; SFTP write opens with resume when a partial file exists (per segment on large files).
|
||||
- **Retry:** Reconnects dedicated sessions; segment-level and full-copy retries with backoff; fresh SSH pairs after repeated failures (lane reset).
|
||||
- **Stall detection:** ~45 s without progress on a segment; hung transfer/reconnect probing on status polls.
|
||||
- **Cancel:** Aborts in-flight SFTP; user can clean up destination paths that were created or partially written.
|
||||
- **Overlap guard:** Refuses transfer when source and destination paths overlap in a destructive way.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No direct host-to-host data path** — Termix must reach **both** hosts independently (with each host’s jump/proxy settings).
|
||||
2. **Not the same as S2S SSH tunnels** — Tunnels in Host Manager forward TCP ports; they do not carry file-manager transfers today.
|
||||
3. **Throughput** — Remote-to-remote speed is bounded by Termix CPU/RAM and min(Termix↔source, Termix↔dest) links; very large files on a small Termix box may be slower than `scp -3` from a powerful desktop.
|
||||
4. **Parallel lanes** — Writes are out of order on disk; fine for copy, not for playing media from a partially written file. Default is 2 lanes; UI may not expose lane count (API default applies).
|
||||
5. **Tar** — Requires `tar` on both Unix hosts; temporary archive under `/tmp` on source during transfer.
|
||||
6. **Windows** — Tar path disabled; per-file SFTP only for Windows endpoints.
|
||||
7. **Jump hosts on S2S tunnels** — Server-to-server **tunnel** connect does not use jump hosts; only transfer/browse SSH does. A host reachable only via jump may work for transfer but not as an S2S tunnel source until tunnel code is aligned.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Things to check |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| No destination hosts listed | Open File Manager on another host; ensure host has File Manager enabled. |
|
||||
| Destination “Authentication required” | Connect File Manager on that host once in this session. |
|
||||
| Transfer fails immediately | SSH from Termix to both hosts (firewall, jump host, SOCKS5, credentials). |
|
||||
| Slow speed | Termix link to slower side; try off-peak; for single huge files, parallel lanes help if CPU/network allow. |
|
||||
| Stuck progress | Wait for stall/reconnect; cancel and retry; check server logs for `host_transfer` / `transfer_ssh_*`. |
|
||||
| Partial files after cancel | Use **Clean up destination** in the toast. |
|
||||
| 28 GB / 25 GB style progress | Usually parallel progress accounting; status polls use destination size probes. |
|
||||
|
||||
## Comparison to manual `scp` between remotes
|
||||
|
||||
See [Unix & Linux: scp from one remote server to another](https://unix.stackexchange.com/questions/85292/scp-from-one-remote-server-to-another-remote-server). Naive `scp one:file two:file` runs **from the first host** and fails unless that host can SSH to the second. `scp -3` relays through your workstation. **Termix relay** is analogous to `scp -3` through the **Termix server**, with richer lifecycle management, not analogous to direct `ssh source 'scp … dest'`.
|
||||
|
||||
---
|
||||
|
||||
## Future work: direct routing via tunnels
|
||||
|
||||
The following are **planned / discussed** enhancements, not shipped in the current build. They build on existing **S2S SSH tunnels** (`src/backend/ssh/tunnel.ts`), which already connect **source → endpoint** using `forwardOut` from the source host to the endpoint’s SSH port.
|
||||
|
||||
### Why tunnels matter
|
||||
|
||||
Many homelabs have a destination that is **only reachable from another host** (e.g. NAS on LAN behind a Pi), while Termix runs elsewhere. Today that destination cannot receive a dedicated `xfer:dst` session from Termix even if an S2S tunnel from Pi → NAS is configured and working.
|
||||
|
||||
### Possible routes (future)
|
||||
|
||||
| Route | Termix SSH legs | Data path | Benefit vs today |
|
||||
| --------------------------------------- | --------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **Relay (current)** | 2 | Termix buffers SFTP | Works when both hosts reachable from Termix. |
|
||||
| **Tunnel-bridged SFTP** | 1 (+ bridge) | Still through Termix memory | Dest reached via source `forwardOut`; fixes reachability; reuses most of current engine. |
|
||||
| **Direct remote (rsync/scp on source)** | 1 (control) | **Source → dest** bytes | Best throughput; Termix orchestrates `rsync`/`scp` on source when forward + tools allow. |
|
||||
|
||||
### Integration ideas (not implemented)
|
||||
|
||||
1. **`transfer-bridge` module** — Shared `forwardOut` probe and `connectDestThroughSource` (extracted from tunnel code); lookup matching `tunnel_connections` on the source host record.
|
||||
2. **Route resolver** — Auto-select relay vs bridged vs direct; expose route in method preview (“via Termix” vs “direct host-to-host”).
|
||||
3. **Reuse active S2S tunnel** — If Host Manager tunnel source→dest is already connected, reuse `endpointClient` instead of opening a second bridge.
|
||||
4. **Jump hosts on S2S tunnel source** — Align tunnel connect with file-manager jump chains so tunnel and transfer eligibility match.
|
||||
5. **Fallback** — Always fall back to current relay when probe or remote `rsync` fails (Windows, missing tools, forwarding denied).
|
||||
|
||||
### What would be preserved
|
||||
|
||||
Cancel, partial cleanup, retry/resume (rsync `--partial` on direct path; existing SFTP segment resume on relay/bridged), dedicated sessions, transfer monitor, recent destinations, folder shortcuts, and tar/per-file method selection (with direct-path variants for multi-file).
|
||||
|
||||
### References
|
||||
|
||||
- Internal plan: `.cursor/plans/host-to-host_direct_transfer_*.plan.md` (if present in your checkout).
|
||||
- Tunnel implementation: [`src/backend/ssh/tunnel.ts`](../src/backend/ssh/tunnel.ts) — `connectEndpointThroughSource`, `establishManagedS2STunnel`.
|
||||
- Transfer engine: [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts).
|
||||
|
||||
---
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**إدارة Docker:**
|
||||
تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
|
||||
**إدارة Docker و Podman:**
|
||||
تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. يدعم كلاً من Docker و Podman كبيئة تشغيل للحاويات. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**تكامل Tailscale:**
|
||||
عرض أجهزة شبكتك من Tailscale لإضافتها بسرعة كمضيفات، والاتصال عبر Tailscale SSH كطريقة مصادقة، مما يتيح لقوائم تحكم الوصول في Tailscale التعامل مع التفويض دون الحاجة لتخزين بيانات اعتماد.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**الاتصالات التسلسلية:**
|
||||
الاتصال بالأجهزة التسلسلية (أجهزة التوجيه والمفاتيح والمتحكمات الدقيقة وغيرها) مباشرة من المتصفح أو تطبيق سطح المكتب. ضبط معدل نقل البيانات وبتات البيانات وبتات التوقف والتكافؤ. يستخدم Web Serial API في المتصفحات المدعومة أو خلفية أصلية في تطبيق Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**التنبيهات:**
|
||||
ضبط قواعد تنبيه قائمة على الحدود لمقاييس المضيف (المعالج والذاكرة والقرص وغيرها) والحصول على إشعارات عبر ntfy أو webhooks عند إطلاقها. عرض التنبيهات النشطة والمحلولة في سجل التاريخ.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**الصفحة الرئيسية:**
|
||||
صفحة رئيسية قابلة للتخصيص بالكامل مع شبكة أدوات قابلة للسحب والإفلات. أضف أدوات لحالة المضيف وروابط الخدمات والساعات والملاحظات وخلاصات RSS والطقس وحاويات Docker ومخططات مقاييس المضيف والطرفيات المضمنة والإطارات المضمنة وأكثر.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## التبرع
|
||||
|
||||
Termix مجاني ومفتوح المصدر. إذا وجدته مفيدًا، فكّر في [التبرع](https://donate.termix.site/) للمساعدة في تغطية تكاليف الخادم ووقت التطوير.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## لقطات الشاشة
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## الميزات المخططة
|
||||
|
||||
راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/5) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker 管理:**
|
||||
启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
|
||||
**Docker 和 Podman 管理:**
|
||||
启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。同时支持 Docker 和 Podman 作为容器运行时。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale 集成:**
|
||||
列出您 Tailscale 网络中的设备以快速添加为主机,并使用 Tailscale SSH 作为身份验证方式,让您的 Tailscale ACL 处理授权而无需存储凭据。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
创建角色并在用户/角色之间共享主机。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**串口连接:**
|
||||
直接从浏览器或桌面应用连接到串口设备(路由器、交换机、微控制器等)。配置波特率、数据位、停止位和奇偶校验。在支持的浏览器中使用 Web Serial API,或在 Electron 应用中使用原生后端。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**告警:**
|
||||
为主机指标(CPU、内存、磁盘等)设置基于阈值的告警规则,并通过 ntfy 或 webhook 接收触发通知。在历史日志中查看触发和已解决的告警。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**主页:**
|
||||
具有拖放小组件网格的完全可定制主页。添加主机状态、服务链接、时钟、笔记、RSS 订阅、天气、Docker 容器、主机指标图表、嵌入式终端、iframe 等小组件。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## 捐赠
|
||||
|
||||
Termix 免费且开源。如果您觉得它有用,请考虑[捐赠](https://donate.termix.site/)以帮助支付服务器费用和开发时间。
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## 展示
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>某些视频和图像可能已过时,或者可能无法完美展示功能。</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## 计划功能
|
||||
|
||||
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
查看 [Projects](https://github.com/orgs/Termix-SSH/projects/5) 了解所有计划功能。如果您想贡献代码,请参阅 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstutzung fur das Anzeig
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker-Verwaltung:**
|
||||
Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
|
||||
**Docker- und Podman-Verwaltung:**
|
||||
Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container uber Docker-Exec-Terminal steuern. Unterstutzt sowohl Docker als auch Podman als Container-Laufzeitumgebung. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale-Integration:**
|
||||
Gerate aus Ihrem Tailnet auflisten, um sie schnell als Hosts hinzuzufugen, und mit Tailscale SSH als Authentifizierungsmethode verbinden, sodass Ihre Tailnet-ACLs die Autorisierung ubernehmen, ohne Anmeldedaten speichern zu mussen.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Rollen erstellen und Hosts uber Benutzer/Rollen teilen.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Serielle Verbindungen:**
|
||||
Verbinden Sie sich direkt vom Browser oder der Desktop-App aus mit seriellen Geraten (Router, Switches, Mikrocontroller usw.). Konfigurieren Sie Baudrate, Datenbits, Stoppbits und Paritat. Verwendet die Web Serial API in unterstutzten Browsern oder ein natives Backend in der Electron-App.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Warnmeldungen:**
|
||||
Legen Sie schwellenwertbasierte Warnregeln fur Host-Metriken (CPU, Arbeitsspeicher, Festplatte usw.) fest und erhalten Sie Benachrichtigungen uber ntfy oder Webhooks, wenn diese ausgelost werden. Zeigen Sie ausgeloste und aufgeloste Warnmeldungen in einem Verlaufsprotokoll an.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Startseite:**
|
||||
Eine vollstandig anpassbare Startseite mit einem Drag-and-Drop-Widget-Raster. Fugen Sie Widgets fur Hoststatus, Service-Links, Uhren, Notizen, RSS-Feeds, Wetter, Docker-Container, Host-Metrik-Diagramme, eingebettete Terminals, iFrames und mehr hinzu.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Spenden
|
||||
|
||||
Termix ist kostenlos und Open Source. Wenn Sie es nützlich finden, erwägen Sie eine [Spende](https://donate.termix.site/), um Serverkosten und Entwicklungszeit zu decken.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Einige Videos und Bilder konnen veraltet sein oder Funktionen moglicherweise nicht perfekt darstellen.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Geplante Funktionen
|
||||
|
||||
Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/5) fur alle geplanten Funktionen. Wenn Sie beitragen mochten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Gestione archivos directamente en servidores remotos con soporte para visualizar
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestion de Docker:**
|
||||
Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
|
||||
**Gestion de Docker y Podman:**
|
||||
Inicie, detenga, pause, elimine contenedores. Vea estadisticas de contenedores. Controle contenedores usando el terminal docker exec. Compatible con Docker y Podman como entorno de ejecucion de contenedores. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Integracion con Tailscale:**
|
||||
Liste dispositivos de su red Tailscale para agregarlos rapidamente como hosts y conectese usando Tailscale SSH como metodo de autenticacion, permitiendo que las ACL de Tailscale gestionen la autorizacion sin almacenar credenciales.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Cree roles y comparta hosts entre usuarios/roles.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Conexiones Serie:**
|
||||
Conectese a dispositivos serie (routers, switches, microcontroladores, etc.) directamente desde el navegador o la aplicacion de escritorio. Configure la tasa de baudios, bits de datos, bits de parada y paridad. Utiliza la Web Serial API en navegadores compatibles o un backend nativo en la aplicacion Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Alertas:**
|
||||
Configure reglas de alerta basadas en umbrales para metricas del host (CPU, memoria, disco, etc.) y reciba notificaciones a traves de ntfy o webhooks cuando se activen. Vea las alertas activas y resueltas en un historial de registros.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Pagina de Inicio:**
|
||||
Una pagina de inicio completamente personalizable con una cuadricula de widgets de arrastrar y soltar. Agregue widgets para estado del host, enlaces de servicios, relojes, notas, feeds RSS, clima, contenedores Docker, graficos de metricas del host, terminales integrados, iframes y mas.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Donar
|
||||
|
||||
Termix es gratuito y de código abierto. Si lo encuentras útil, considera [donar](https://donate.termix.site/) para ayudar a cubrir los costos del servidor y el tiempo de desarrollo.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Capturas de Pantalla
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Algunos videos e imagenes pueden estar desactualizados o no mostrar perfectamente las caracteristicas.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Caracteristicas Planeadas
|
||||
|
||||
Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/5) para todas las caracteristicas planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Gerez les fichiers directement sur les serveurs distants avec support de la visu
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestion Docker:**
|
||||
Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer.
|
||||
**Gestion Docker et Podman:**
|
||||
Demarrez, arretez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Controlez les conteneurs via le terminal docker exec. Compatible avec Docker et Podman comme environnement d'execution de conteneurs. Non concu pour remplacer Portainer ou Dockge, mais plutot pour gerer simplement vos conteneurs plutot que de les creer.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Integration Tailscale:**
|
||||
Listez les appareils de votre reseau Tailscale pour les ajouter rapidement comme hotes, et connectez-vous en utilisant Tailscale SSH comme methode d'authentification, laissant les ACL de votre reseau gerer l'autorisation sans stocker de credentials.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Creez des roles et partagez des hotes entre utilisateurs/roles.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Connexions Serie:**
|
||||
Connectez-vous a des appareils serie (routeurs, commutateurs, microcontroleurs, etc.) directement depuis le navigateur ou l'application bureau. Configurez le debit en bauds, les bits de donnees, les bits d'arret et la parite. Utilise l'API Web Serial dans les navigateurs compatibles ou un backend natif dans l'application Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Alertes:**
|
||||
Definissez des regles d'alerte basees sur des seuils pour les metriques d'hote (CPU, memoire, disque, etc.) et recevez des notifications via ntfy ou webhooks lorsqu'elles se declenchent. Consultez les alertes actives et resolues dans un journal d'historique.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Page d'accueil:**
|
||||
Une page d'accueil entierement personnalisable avec une grille de widgets glisser-deposer. Ajoutez des widgets pour l'etat des hotes, les liens de services, les horloges, les notes, les flux RSS, la meteo, les conteneurs Docker, les graphiques de metriques d'hote, les terminaux integres, les iframes et plus encore.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Faire un don
|
||||
|
||||
Termix est gratuit et open source. Si vous le trouvez utile, pensez à [faire un don](https://donate.termix.site/) pour aider à couvrir les coûts de serveur et le temps de développement.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Captures d'ecran
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Certaines videos et images peuvent etre obsoletes ou ne pas presenter parfaitement les fonctionnalites.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Fonctionnalites prevues
|
||||
|
||||
Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/5) pour toutes les fonctionnalites prevues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker प्रबंधन:**
|
||||
कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
|
||||
**Docker और Podman प्रबंधन:**
|
||||
कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। Docker और Podman दोनों को कंटेनर रनटाइम के रूप में सपोर्ट करता है। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale एकीकरण:**
|
||||
अपने Tailscale नेटवर्क के डिवाइस सूचीबद्ध करें ताकि उन्हें जल्दी से होस्ट के रूप में जोड़ा जा सके, और Tailscale SSH को प्रमाणीकरण विधि के रूप में उपयोग करके कनेक्ट करें, जिससे आपके Tailscale ACL क्रेडेंशियल संग्रहीत किए बिना प्राधिकरण संभाल सकें।
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**सीरियल कनेक्शन:**
|
||||
सीरियल डिवाइस (राउटर, स्विच, माइक्रोकंट्रोलर आदि) से सीधे ब्राउज़र या डेस्कटॉप ऐप से कनेक्ट करें। बॉड रेट, डेटा बिट्स, स्टॉप बिट्स और पैरिटी कॉन्फ़िगर करें। समर्थित ब्राउज़र में Web Serial API या Electron ऐप में नेटिव बैकएंड का उपयोग करता है।
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**अलर्ट:**
|
||||
होस्ट मेट्रिक्स (CPU, मेमोरी, डिस्क आदि) पर थ्रेशोल्ड-आधारित अलर्ट नियम सेट करें और जब वे ट्रिगर हों तो ntfy या webhooks के माध्यम से सूचना पाएँ। इतिहास लॉग में सक्रिय और हल किए गए अलर्ट देखें।
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**होमपेज:**
|
||||
ड्रैग-एंड-ड्रॉप विजेट ग्रिड के साथ पूरी तरह से कस्टमाइज़ करने योग्य होमपेज। होस्ट स्टेटस, सर्विस लिंक, घड़ियाँ, नोट्स, RSS फ़ीड, मौसम, Docker कंटेनर, होस्ट मेट्रिक्स चार्ट, एम्बेडेड टर्मिनल, iframes और अन्य के लिए विजेट जोड़ें।
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## दान करें
|
||||
|
||||
Termix मुफ़्त और ओपन सोर्स है। यदि आपको यह उपयोगी लगता है, तो सर्वर लागत और विकास समय में मदद के लिए [दान करें](https://donate.termix.site/)।
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## स्क्रीनशॉट
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं।</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## नियोजित विशेषताएँ
|
||||
|
||||
सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
|
||||
सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/5) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Gestisci i file direttamente sui server remoti con supporto per la visualizzazio
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gestione Docker:**
|
||||
Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
||||
**Gestione Docker e Podman:**
|
||||
Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Supporta sia Docker che Podman come runtime dei container. Non e stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Integrazione Tailscale:**
|
||||
Elenca i dispositivi della tua rete Tailscale per aggiungerli rapidamente come host, e connettiti utilizzando Tailscale SSH come metodo di autenticazione, lasciando che le ACL della tua rete gestiscano l'autorizzazione senza memorizzare credenziali.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Crea ruoli e condividi host tra utenti/ruoli.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Connessioni Seriali:**
|
||||
Connettiti a dispositivi seriali (router, switch, microcontrollori, ecc.) direttamente dal browser o dall'app desktop. Configura baud rate, bit di dati, bit di stop e parita. Utilizza la Web Serial API nei browser supportati o un backend nativo nell'app Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Avvisi:**
|
||||
Imposta regole di avviso basate su soglie per le metriche dell'host (CPU, memoria, disco, ecc.) e ricevi notifiche tramite ntfy o webhook quando si attivano. Visualizza gli avvisi attivi e risolti in un registro storico.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Homepage:**
|
||||
Una homepage completamente personalizzabile con una griglia di widget drag-and-drop. Aggiungi widget per lo stato dell'host, link ai servizi, orologi, note, feed RSS, meteo, container Docker, grafici delle metriche dell'host, terminali incorporati, iframe e altro ancora.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Dona
|
||||
|
||||
Termix è gratuito e open source. Se lo trovi utile, considera di [donare](https://donate.termix.site/) per aiutare a coprire i costi del server e il tempo di sviluppo.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Screenshot
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalita.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Funzionalita Pianificate
|
||||
|
||||
Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/5) per tutte le funzionalita pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker管理:**
|
||||
コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
|
||||
**DockerおよびPodman管理:**
|
||||
コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。DockerとPodmanの両方をコンテナランタイムとしてサポートしています。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscaleインテグレーション:**
|
||||
TailnetのデバイスをリストしてホストとしてすばやくH追加し、Tailscale SSHを認証方法として使用して接続します。これにより、TailnetのACLが認証情報を保存せずに認可を処理します。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
ロールを作成し、ユーザー/ロール間でホストを共有できます。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**シリアル接続:**
|
||||
ブラウザまたはデスクトップアプリからシリアルデバイス(ルーター、スイッチ、マイクロコントローラーなど)に直接接続できます。ボーレート、データビット、ストップビット、パリティを設定できます。対応ブラウザではWeb Serial APIを使用し、Electronアプリではネイティブバックエンドを使用します。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**アラート:**
|
||||
ホストメトリクス(CPU、メモリ、ディスクなど)に対してしきい値ベースのアラートルールを設定し、発動時にntfyまたはwebhookで通知を受け取れます。発動中および解決済みのアラートを履歴ログで確認できます。
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**ホームページ:**
|
||||
ドラッグ&ドロップのウィジェットグリッドを備えた完全カスタマイズ可能なホームページ。ホストステータス、サービスリンク、時計、メモ、RSSフィード、天気、Dockerコンテナ、ホストメトリクスグラフ、埋め込みターミナル、iframeなどのウィジェットを追加できます。
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## 寄付
|
||||
|
||||
Termix は無料のオープンソースプロジェクトです。便利だと感じた場合は、サーバーコストと開発時間のために[寄付](https://donate.termix.site/)をご検討ください。
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>動画や画像の一部は最新ではない場合や、機能を完全に紹介できていない場合があります。</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## 予定されている機能
|
||||
|
||||
すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/2)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
|
||||
すべての予定機能については[Projects](https://github.com/orgs/Termix-SSH/projects/5)をご覧ください。コントリビュートをご希望の方は[Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)をご覧ください。
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker 관리:**
|
||||
컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
|
||||
**Docker 및 Podman 관리:**
|
||||
컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Docker와 Podman을 모두 컨테이너 런타임으로 지원. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale 통합:**
|
||||
Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가하고, Tailscale SSH를 인증 방법으로 사용하여 연결함으로써 자격 증명을 저장하지 않고도 네트워크 ACL이 권한 부여를 처리하도록 합니다.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
역할을 생성하고 사용자/역할 간에 호스트 공유.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**시리얼 연결:**
|
||||
브라우저 또는 데스크톱 앱에서 직접 시리얼 장치(라우터, 스위치, 마이크로컨트롤러 등)에 연결. 보드레이트, 데이터 비트, 스톱 비트, 패리티 구성. 지원 브라우저에서는 Web Serial API를, Electron 앱에서는 네이티브 백엔드를 사용합니다.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**알림:**
|
||||
호스트 메트릭(CPU, 메모리, 디스크 등)에 대한 임계값 기반 알림 규칙을 설정하고 트리거될 때 ntfy 또는 웹훅을 통해 알림 수신. 기록 로그에서 발생 중인 알림과 해결된 알림 확인.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**홈페이지:**
|
||||
드래그 앤 드롭 위젯 그리드를 갖춘 완전 맞춤형 홈페이지. 호스트 상태, 서비스 링크, 시계, 메모, RSS 피드, 날씨, Docker 컨테이너, 호스트 메트릭 차트, 임베디드 터미널, iframe 등의 위젯 추가 가능.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## 후원
|
||||
|
||||
Termix는 무료 오픈소스 프로젝트입니다. 유용하게 사용하고 있다면 서버 비용과 개발 시간을 위해 [후원](https://donate.termix.site/)을 고려해 주세요.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## 스크린샷
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
|
||||
모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/5)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Gerencie arquivos diretamente em servidores remotos com suporte para visualizar
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Gerenciamento de Docker:**
|
||||
Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los.
|
||||
**Gerenciamento de Docker e Podman:**
|
||||
Inicie, pare, pause, remova conteineres. Visualize estatisticas de conteineres. Controle conteineres usando o terminal Docker Exec. Suporta Docker e Podman como ambiente de execucao de conteineres. Nao foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus conteineres em vez de cria-los.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Integracao com Tailscale:**
|
||||
Liste dispositivos da sua rede Tailscale para adicioná-los rapidamente como hosts, e conecte-se usando Tailscale SSH como metodo de autenticacao, deixando as ACLs da sua rede gerenciar a autorizacao sem armazenar credenciais.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Crie funcoes e compartilhe hosts entre usuarios/funcoes.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Conexoes Seriais:**
|
||||
Conecte-se a dispositivos seriais (roteadores, switches, microcontroladores, etc.) diretamente do navegador ou do aplicativo desktop. Configure taxa de baud, bits de dados, bits de parada e paridade. Usa a Web Serial API em navegadores suportados ou um backend nativo no aplicativo Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Alertas:**
|
||||
Defina regras de alerta baseadas em limites para metricas do host (CPU, memoria, disco, etc.) e receba notificacoes via ntfy ou webhooks quando forem ativadas. Visualize alertas ativos e resolvidos em um historico de registros.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Pagina Inicial:**
|
||||
Uma pagina inicial totalmente personalizavel com uma grade de widgets de arrastar e soltar. Adicione widgets para status do host, links de servicos, relogios, notas, feeds RSS, clima, conteineres Docker, graficos de metricas do host, terminais incorporados, iframes e mais.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Doar
|
||||
|
||||
Termix é gratuito e de código aberto. Se o achar útil, considere [doar](https://donate.termix.site/) para ajudar a cobrir os custos de servidor e o tempo de desenvolvimento.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Capturas de Tela
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Alguns videos e imagens podem estar desatualizados ou podem nao mostrar perfeitamente as funcionalidades.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Funcionalidades Planejadas
|
||||
|
||||
Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/5) para todas as funcionalidades planejadas. Se voce deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Termix - это платформа для управления серверам
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Управление Docker:**
|
||||
Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
|
||||
**Управление Docker и Podman:**
|
||||
Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Поддерживает как Docker, так и Podman в качестве среды выполнения контейнеров. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Интеграция с Tailscale:**
|
||||
Список устройств вашей сети Tailscale для быстрого добавления их в качестве хостов и подключение через Tailscale SSH в качестве метода аутентификации, позволяя ACL вашей сети управлять авторизацией без хранения учётных данных.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Создание ролей и предоставление общего доступа к хостам для пользователей/ролей.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Последовательные подключения:**
|
||||
Подключение к последовательным устройствам (маршрутизаторы, коммутаторы, микроконтроллеры и т. д.) напрямую из браузера или приложения для рабочего стола. Настройка скорости передачи данных, битов данных, стоп-битов и чётности. Использует Web Serial API в поддерживаемых браузерах или нативный бэкенд в приложении Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Оповещения:**
|
||||
Настройте правила оповещений на основе пороговых значений для метрик хоста (CPU, память, диск и т. д.) и получайте уведомления через ntfy или вебхуки при их срабатывании. Просматривайте активные и разрешённые оповещения в журнале истории.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Домашняя страница:**
|
||||
Полностью настраиваемая домашняя страница с сеткой виджетов с перетаскиванием. Добавляйте виджеты для статуса хоста, ссылок на сервисы, часов, заметок, RSS-лент, погоды, контейнеров Docker, графиков метрик хоста, встроенных терминалов, iframe и многого другого.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Пожертвование
|
||||
|
||||
Termix — бесплатный проект с открытым исходным кодом. Если он вам полезен, рассмотрите возможность [пожертвования](https://donate.termix.site/) для покрытия расходов на серверы и время разработки.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Скриншоты
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Запланированные функции
|
||||
|
||||
Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/5) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Uzak sunuculardaki dosyalari dogrudan yonetin; kod, goruntu, ses ve video gorunt
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Docker Yonetimi:**
|
||||
Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir.
|
||||
**Docker ve Podman Yonetimi:**
|
||||
Konteynerleri baslatın, durdurun, duraklatın, kaldirin. Konteyner istatistiklerini goruntuleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Docker ve Podman'i konteyner calisma ortami olarak destekler. Portainer veya Dockge'nin yerini almak icin degil, konteynerlerinizi olusturmak yerine basitce yonetmek icin tasarlanmistir.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tailscale Entegrasyonu:**
|
||||
Tailscale aginizdaki cihazlari listeleyerek hizlica ana bilgisayar olarak ekleyin ve kimlik dogrulama yontemi olarak Tailscale SSH kullanarak baglanin; bu sayede ag ACL'leriniz kimlik bilgileri depolamadan yetkilendirmeyi yonetir.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Roller olusturun ve ana bilgisayarlari kullanicilar/roller arasinda paylasin.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Seri Baglantilar:**
|
||||
Seri cihazlara (router, switch, mikrodenetleyici vb.) dogrudan tarayici veya masaustu uygulamasindan baglanin. Baud hizi, veri bitleri, durdurma bitleri ve parite yapilandirin. Desteklenen tarayicilarda Web Serial API, Electron uygulamasinda yerel arka ucu kullanir.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Uyarilar:**
|
||||
Ana bilgisayar metrikleri (CPU, bellek, disk vb.) icin esik tabanli uyari kurallari belirleyin ve tetiklendiklerinde ntfy veya webhook araciligiyla bildirim alin. Gecmis gunlugunde tetiklenen ve cozulen uyarilari goruntuleyin.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Ana Sayfa:**
|
||||
Surukleme ve birakma widget izgarasina sahip tamamen ozerlestirilebilir bir ana sayfa. Ana bilgisayar durumu, hizmet baglantilari, saatler, notlar, RSS besleme, hava durumu, Docker konteynerleri, ana bilgisayar metrik grafikleri, gomulu terminaller, iframe ve daha fazlasi icin widget ekleyin.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Bağış Yapın
|
||||
|
||||
Termix ücretsiz ve açık kaynaklıdır. Faydalı buluyorsanız, sunucu maliyetleri ve geliştirme süresine katkıda bulunmak için [bağış yapmayı](https://donate.termix.site/) düşünebilirsiniz.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Ekran Goruntuleri
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Bazi videolar ve gorseller guncel olmayabilir veya ozellikleri tam olarak yansitmayabilir.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Planlanan Ozellikler
|
||||
|
||||
Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin.
|
||||
Tum planlanan ozellikler icin [Projeler](https://github.com/orgs/Termix-SSH/projects/5) sayfasina bakin. Katkida bulunmak istiyorsaniz, [Katkida Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasina bakin.
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
+56
-8
@@ -28,10 +28,17 @@
|
||||
<img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
|
||||
<img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
|
||||
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
|
||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="../repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
|
||||
<br />
|
||||
@@ -87,37 +94,65 @@ Quan ly tep truc tiep tren may chu tu xa voi ho tro xem va chinh sua ma, hinh an
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Quan Ly Docker:**
|
||||
Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung.
|
||||
**Quan Ly Docker va Podman:**
|
||||
Khoi dong, dung, tam dung, xoa container. Xem thong ke container. Dieu khien container bang terminal docker exec. Ho tro ca Docker va Podman lam moi truong chay container. Khong duoc tao ra de thay the Portainer hay Dockge ma don gian la de quan ly container cua ban thay vi tao moi chung.
|
||||
|
||||
</td>
|
||||
<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>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Tich Hop Tailscale:**
|
||||
Liet ke cac thiet bi trong mang Tailscale de nhanh chong them vao lam may chu, va ket noi bang Tailscale SSH lam phuong thuc xac thuc, de cac ACL mang xu ly uy quyen ma khong can luu tru thong tin xac thuc.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**RBAC:**
|
||||
Tao vai tro va chia se may chu giua nguoi dung/vai tro.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Ket Noi Noi Tiep:**
|
||||
Ket noi voi cac thiet bi noi tiep (router, switch, vi dieu khien, v.v.) truc tiep tu trinh duyet hoac ung dung may tinh. Cau hinh toc do baud, bit du lieu, bit dung va chan le. Su dung Web Serial API tren trinh duyet duoc ho tro hoac backend ban dia trong ung dung Electron.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Canh Bao:**
|
||||
Dat cac quy tac canh bao dua tren nguong cho chi so may chu (CPU, bo nho, o dia, v.v.) va nhan thong bao qua ntfy hoac webhook khi chung khi toa. Xem canh bao dang kich hoat va da giai quyet trong nhat ky lich su.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Trang Chu:**
|
||||
Trang chu co the tuy chinh hoan toan voi luoi widget keo va tha. Them widget cho trang thai may chu, lien ket dich vu, dong ho, ghi chu, feed RSS, thoi tiet, container Docker, bieu do chi so may chu, terminal nhung, iframe va nhieu hon nua.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
@@ -170,7 +205,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>
|
||||
|
||||
@@ -252,6 +288,14 @@ networks:
|
||||
|
||||
<br />
|
||||
|
||||
## Quyên góp
|
||||
|
||||
Termix là dự án miễn phí và mã nguồn mở. Nếu bạn thấy hữu ích, hãy cân nhắc [quyên góp](https://donate.termix.site/) để giúp trang trải chi phí máy chủ và thời gian phát triển.
|
||||
|
||||
<a href="https://donate.termix.site/"><img src="../repo-images/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
## Anh Chup Man Hinh
|
||||
|
||||
<div align="center">
|
||||
@@ -294,6 +338,10 @@ networks:
|
||||
<td><img src="../repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="../repo-images/Image 15.png" alt="Termix Screenshot 15" width="400" /></td>
|
||||
<td><img src="../repo-images/Image 16.png" alt="Termix Screenshot 16" width="400" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Mot so video va hinh anh co the da loi thoi hoac khong the hien chinh xac hoan toan cac tinh nang.</sub>
|
||||
@@ -304,7 +352,7 @@ networks:
|
||||
|
||||
## Tinh Nang Du Kien
|
||||
|
||||
Xem [Du An](https://github.com/orgs/Termix-SSH/projects/2) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
Xem [Du An](https://github.com/orgs/Termix-SSH/projects/5) de biet tat ca cac tinh nang du kien. Neu ban muon dong gop, xem [Dong Gop](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 527 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 534 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="52" role="img" aria-label="Monthly donation goal">
|
||||
<title>Monthly donation goal</title>
|
||||
<rect width="400" height="52" rx="6" fill="#0c0d0b"/>
|
||||
<text x="200" y="13" font-family="sans-serif" font-size="11" fill="#F39044" text-anchor="middle">Monthly Donation Goal ... / $750</text>
|
||||
<rect x="20" y="20" width="360" height="8" rx="4" fill="#F3904433"/>
|
||||
<rect x="20" y="20" width="0" height="8" rx="4" fill="#F39044"/>
|
||||
<text x="200" y="44" font-family="sans-serif" font-size="10" fill="#F3904499" text-anchor="middle">Loading...</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 610 B |
@@ -0,0 +1,120 @@
|
||||
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 (;;) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const filePath = path.join(
|
||||
const guacdClientPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
@@ -9,13 +9,22 @@ const filePath = path.join(
|
||||
"lib",
|
||||
"GuacdClient.js",
|
||||
);
|
||||
const cryptPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"Crypt.js",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||
|
||||
// Patch 1: version acceptance list
|
||||
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
||||
@@ -41,36 +50,185 @@ const newConnect =
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
|
||||
// Patch 4: answer guacd's dynamic argument requests locally.
|
||||
// macOS Screen Sharing can request VNC username/password through the
|
||||
// post-handshake `required`/`require` flow. guacamole-lite forwards those
|
||||
// instructions to the browser, but Termix already keeps the credentials in the
|
||||
// server-side token and the browser does not provide an onrequired handler.
|
||||
const oldSendBuffer =
|
||||
" this.lastActivity = Date.now();\n" + " this.sendBuffer = '';";
|
||||
const newSendBuffer =
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.sendBuffer = '';\n" +
|
||||
" this.nextArgumentStreamIndex = 0;";
|
||||
|
||||
const oldSendInstructionBlock =
|
||||
" sendInstruction(instruction) {\n" +
|
||||
" // convert every element in the instruction array to a string. convert null to an empty string\n" +
|
||||
" instruction = instruction.map((element) => {\n" +
|
||||
" if (element === null || element === undefined) {\n" +
|
||||
" return '';\n" +
|
||||
" }\n" +
|
||||
" return String(element);\n" +
|
||||
" });\n" +
|
||||
"\n" +
|
||||
" const instructionString = GuacamoleParser.toInstruction(instruction);\n" +
|
||||
" this.send(instructionString);\n" +
|
||||
" }\n";
|
||||
const newSendInstructionBlock =
|
||||
oldSendInstructionBlock +
|
||||
"\n" +
|
||||
" sendArgumentValue(name, value) {\n" +
|
||||
" const stream = this.nextArgumentStreamIndex++;\n" +
|
||||
" this.sendInstruction(['argv', stream, 'text/plain', name]);\n" +
|
||||
" this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" +
|
||||
" this.sendInstruction(['end', stream]);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" sendRequiredArguments(params) {\n" +
|
||||
" params.forEach((name) => {\n" +
|
||||
" this.sendArgumentValue(name, this.connectionSettings[name]);\n" +
|
||||
" });\n" +
|
||||
" }\n";
|
||||
|
||||
const oldReadyHandler =
|
||||
' // Handle "ready" instruction\n' +
|
||||
" if (opcode === 'ready') {";
|
||||
const newReadyHandler =
|
||||
" // Handle dynamic argument requests from guacd\n" +
|
||||
" if (opcode === 'required' || opcode === 'require') {\n" +
|
||||
" this.sendRequiredArguments(params);\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
oldReadyHandler;
|
||||
|
||||
let patched = false;
|
||||
|
||||
if (!content.includes(newVersionCheck)) {
|
||||
if (!content.includes(oldVersionCheck)) {
|
||||
if (!guacdClientContent.includes(newVersionCheck)) {
|
||||
if (!guacdClientContent.includes(oldVersionCheck)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Version check target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldVersionCheck, newVersionCheck);
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldVersionCheck,
|
||||
newVersionCheck,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!content.includes(newTimezone)) {
|
||||
if (!content.includes(oldTimezone)) {
|
||||
if (!guacdClientContent.includes(newTimezone)) {
|
||||
if (!guacdClientContent.includes(oldTimezone)) {
|
||||
console.log("[patch-guacamole-lite] Timezone target not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldTimezone, newTimezone);
|
||||
guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!content.includes(newConnect)) {
|
||||
if (!content.includes(oldConnect)) {
|
||||
if (!guacdClientContent.includes(newConnect)) {
|
||||
if (!guacdClientContent.includes(oldConnect)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Connect target not found, skipping name patch",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldConnect, newConnect);
|
||||
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) {
|
||||
if (!guacdClientContent.includes(oldSendBuffer)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Argument stream index target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!guacdClientContent.includes("sendRequiredArguments(params) {")) {
|
||||
if (!guacdClientContent.includes(oldSendInstructionBlock)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Required argument helper target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldSendInstructionBlock,
|
||||
newSendInstructionBlock,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!guacdClientContent.includes("opcode === 'required' || opcode === 'require'")
|
||||
) {
|
||||
if (!guacdClientContent.includes(oldReadyHandler)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Required opcode target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldReadyHandler,
|
||||
newReadyHandler,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings,
|
||||
// which corrupts IV/ciphertext bytes and non-ASCII connection settings such as
|
||||
// RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and
|
||||
// decode the plaintext JSON as UTF-8.
|
||||
const oldDecryptBlock =
|
||||
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
|
||||
"\n" +
|
||||
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
|
||||
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" +
|
||||
" decrypted += decipher.final('ascii');";
|
||||
const oldPartiallyPatchedDecryptBlock =
|
||||
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
|
||||
"\n" +
|
||||
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
|
||||
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" +
|
||||
" decrypted += decipher.final('utf8');";
|
||||
const newDecryptBlock =
|
||||
" const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" +
|
||||
"\n" +
|
||||
" const iv = Buffer.from(encoded.iv, 'base64');\n" +
|
||||
" const value = Buffer.from(encoded.value, 'base64');\n" +
|
||||
"\n" +
|
||||
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" +
|
||||
"\n" +
|
||||
" let decrypted = decipher.update(value, undefined, 'utf8');\n" +
|
||||
" decrypted += decipher.final('utf8');";
|
||||
|
||||
if (!cryptContent.includes(newDecryptBlock)) {
|
||||
if (cryptContent.includes(oldDecryptBlock)) {
|
||||
cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock);
|
||||
} else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) {
|
||||
cryptContent = cryptContent.replace(
|
||||
oldPartiallyPatchedDecryptBlock,
|
||||
newDecryptBlock,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] UTF-8 token decrypt target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
|
||||
@@ -79,7 +237,8 @@ if (!patched) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||
fs.writeFileSync(cryptPath, cryptContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with name handshake instruction",
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("patch-guacamole-lite", () => {
|
||||
it("handles guacd dynamic argument requests", () => {
|
||||
const guacdClientPath = path.join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"GuacdClient.js",
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(guacdClientPath, "utf8");
|
||||
|
||||
expect(content).toContain("sendRequiredArguments(params)");
|
||||
expect(content).toContain("opcode === 'required' || opcode === 'require'");
|
||||
expect(content).toContain("this.sendInstruction(['argv'");
|
||||
expect(content).toContain("this.sendInstruction(['blob'");
|
||||
expect(content).toContain("this.sendInstruction(['end'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
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 getVideoStatus(accessToken, videoId) {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new Error(`videos.list failed (${res.status}): ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
return json.items?.[0]?.status?.privacyStatus ?? null;
|
||||
}
|
||||
|
||||
async function setVideoPublic(accessToken, videoId) {
|
||||
const status = await getVideoStatus(accessToken, videoId);
|
||||
if (status === "public") {
|
||||
console.log(`Video ${videoId} is already public, skipping.`);
|
||||
return;
|
||||
}
|
||||
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,18 @@ 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 termixIdRoutes from "./routes/termix-id.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1777,11 +1784,18 @@ 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);
|
||||
app.use("/termix-id", termixIdRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/", alertRulesRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
@@ -1824,7 +1838,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",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js"
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { DatabaseMigration } from "../../utils/database-migration.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
|
||||
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
@@ -196,6 +197,22 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -488,6 +505,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 {
|
||||
@@ -580,12 +653,11 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
const defaultGuacUrl = `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run(defaultGuacUrl);
|
||||
.run(getDefaultGuacdUrl());
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
@@ -627,6 +699,35 @@ 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",
|
||||
"expand_app_rail_on_hover",
|
||||
"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("user_preferences", "status_color_scheme", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dashboard_service_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
@@ -646,6 +747,24 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
device_type TEXT,
|
||||
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
user_verification TEXT NOT NULL DEFAULT 'preferred',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "tags", "TEXT");
|
||||
@@ -664,6 +783,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 +805,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",
|
||||
@@ -701,6 +835,11 @@ const migrateSchema = () => {
|
||||
"override_credential_username",
|
||||
"INTEGER",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"vault_profile_id",
|
||||
"INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
@@ -714,6 +853,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");
|
||||
@@ -896,6 +1046,111 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identities table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce one-Termix-ID-per-user on databases where the table predates the
|
||||
// UNIQUE(user_id) constraint above.
|
||||
try {
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
|
||||
);
|
||||
} catch (indexError) {
|
||||
databaseLogger.warn("Failed to create termix_identities user_id unique index", {
|
||||
operation: "schema_migration",
|
||||
error: indexError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identity_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
label TEXT,
|
||||
comment TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
credential_id INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_keys table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The public resolver fetches keys by identity_id on every request; index it.
|
||||
try {
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
|
||||
);
|
||||
} catch (indexError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_keys identity index", {
|
||||
operation: "schema_migration",
|
||||
error: indexError,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS termix_identity_ca (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identity_id INTEGER NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
validity_days INTEGER NOT NULL DEFAULT 90,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create termix_identity_ca table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -999,30 +1254,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 +1364,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 +1389,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 +1439,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 {
|
||||
@@ -1356,6 +1631,67 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
vault_addr TEXT NOT NULL,
|
||||
vault_namespace TEXT,
|
||||
oidc_mount TEXT,
|
||||
oidc_role TEXT,
|
||||
ssh_mount TEXT,
|
||||
ssh_role TEXT NOT NULL,
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
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 vault_profiles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vault_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
ssh_cert TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_used TEXT,
|
||||
UNIQUE(user_id, profile_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create vault_tokens table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
|
||||
} catch {
|
||||
@@ -1382,6 +1718,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 +1896,234 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
// --- metrics-history begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_metrics_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
mem_percent REAL,
|
||||
disk_percent REAL,
|
||||
net_rx_bytes INTEGER,
|
||||
net_tx_bytes INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
|
||||
ON host_metrics_history (host_id, ts DESC);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create host_metrics_history table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- metrics-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
threshold_value REAL,
|
||||
threshold_duration_seconds INTEGER,
|
||||
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rules table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create notification_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_rule_channels table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alert_firings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved_at TEXT,
|
||||
value REAL,
|
||||
message TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT 'warning',
|
||||
acknowledged INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
|
||||
ON alert_firings (user_id, fired_at DESC);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create alert_firings table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- alerts end ---
|
||||
|
||||
// Seed default metrics history retention setting
|
||||
try {
|
||||
const retentionRow = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'metrics_history_retention_days'")
|
||||
.get();
|
||||
if (!retentionRow) {
|
||||
sqlite
|
||||
.prepare("INSERT INTO settings (key, value) VALUES ('metrics_history_retention_days', '7')")
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize metrics_history_retention_days setting", {
|
||||
operation: "schema_migration",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
// --- homepage begin ---
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_items table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS homepage_layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create homepage_layouts table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
@@ -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")
|
||||
@@ -64,6 +80,25 @@ export const trustedDevices = sqliteTable("trusted_devices", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const webauthnCredentials = sqliteTable("webauthn_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
credentialId: text("credential_id").notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
counter: integer("counter").notNull().default(0),
|
||||
deviceType: text("device_type"),
|
||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull().default(false),
|
||||
transports: text("transports"),
|
||||
userVerification: text("user_verification").notNull().default("preferred"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
});
|
||||
|
||||
export const hosts = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -78,6 +113,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"),
|
||||
@@ -94,9 +130,22 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
overrideCredentialUsername: integer("override_credential_username", {
|
||||
mode: "boolean",
|
||||
}),
|
||||
// When authType is "vault", the host authenticates via a Vault SSH signer
|
||||
// profile (shared settings, no secrets). The signing certificate is obtained
|
||||
// per-user at connect time via an interactive Vault OIDC flow.
|
||||
vaultProfileId: integer("vault_profile_id").references(
|
||||
() => vaultProfiles.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
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 +154,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 +179,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 +196,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 +228,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 +506,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")
|
||||
@@ -637,6 +687,63 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
// Vault SSH signer profiles. These hold ONLY non-secret connection settings and
|
||||
// are intended to be shared across users (shared === true makes a profile
|
||||
// visible to every user on the server). Each user authenticates to Vault via an
|
||||
// interactive OIDC flow at connect time; no tokens or keys are stored here.
|
||||
export const vaultProfiles = sqliteTable("vault_profiles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
tags: text("tags"),
|
||||
// Vault server connection (non-secret)
|
||||
vaultAddr: text("vault_addr").notNull(),
|
||||
vaultNamespace: text("vault_namespace"),
|
||||
// OIDC auth method mount + role used to obtain a Vault token interactively
|
||||
oidcMount: text("oidc_mount"),
|
||||
oidcRole: text("oidc_role"),
|
||||
// SSH secrets engine mount + signer role used to sign the ephemeral key
|
||||
sshMount: text("ssh_mount"),
|
||||
sshRole: text("ssh_role").notNull(),
|
||||
validPrincipals: text("valid_principals"),
|
||||
// Ephemeral keypair algorithm to generate per connection
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// Per-user cache of the ephemeral SSH private key + Vault-signed certificate.
|
||||
// Transient: rows live only until the certificate expires. Secret fields are
|
||||
// encrypted under the user's data-encryption key (see field-crypto.ts).
|
||||
export const vaultTokens = sqliteTable("vault_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
profileId: integer("profile_id")
|
||||
.notNull()
|
||||
.references(() => vaultProfiles.id, { onDelete: "cascade" }),
|
||||
|
||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastUsed: text("last_used"),
|
||||
});
|
||||
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
@@ -680,7 +787,294 @@ 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" }),
|
||||
expandAppRailOnHover: integer("expand_app_rail_on_hover", {
|
||||
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`),
|
||||
});
|
||||
|
||||
// --- termix-id begin ---
|
||||
// A user claims a unique public handle. Their published SSH public keys are
|
||||
// served at an unauthenticated resolver endpoint in authorized_keys format,
|
||||
// so any server can be provisioned with `curl <host>/termix-id/u/<handle> >> ~/.ssh/authorized_keys`.
|
||||
export const termixIdentities = sqliteTable("termix_identities", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
// One Termix ID per user — enforced in schema, not just in code.
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
handle: text("handle").notNull().unique(),
|
||||
description: text("description"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const termixIdentityKeys = sqliteTable("termix_identity_keys", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identityId: integer("identity_id")
|
||||
.notNull()
|
||||
.references(() => termixIdentities.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Public keys are non-secret, so they are stored in plaintext (no field-level
|
||||
// encryption). This is what lets the unauthenticated resolver serve them.
|
||||
publicKey: text("public_key", { length: 8192 }).notNull(),
|
||||
// Raw algorithm token (e.g. "ssh-ed25519"), and a normalized group used for
|
||||
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
|
||||
keyType: text("key_type").notNull(),
|
||||
algorithm: text("algorithm").notNull(),
|
||||
label: text("label"),
|
||||
comment: text("comment"),
|
||||
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
|
||||
source: text("source").notNull().default("manual"),
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// Per-identity certificate authority. Servers that trust this CA (via
|
||||
// TrustedUserCAKeys / @cert-authority) accept any user certificate it signs,
|
||||
// giving central revocation (rotate the CA) and expiry (cert validity).
|
||||
export const termixIdentityCa = sqliteTable("termix_identity_ca", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identityId: integer("identity_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => termixIdentities.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// CA public key (plaintext — it is published); CA private key is field-encrypted.
|
||||
publicKey: text("public_key", { length: 4096 }).notNull(),
|
||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||
validityDays: integer("validity_days").notNull().default(90),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- termix-id end ---
|
||||
|
||||
// --- 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 ---
|
||||
|
||||
// --- metrics-history begin ---
|
||||
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ts: text("ts")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
cpuPercent: real("cpu_percent"),
|
||||
memPercent: real("mem_percent"),
|
||||
diskPercent: real("disk_percent"),
|
||||
netRxBytes: integer("net_rx_bytes"),
|
||||
netTxBytes: integer("net_tx_bytes"),
|
||||
});
|
||||
// --- metrics-history end ---
|
||||
|
||||
// --- alerts begin ---
|
||||
export const alertRules = sqliteTable("alert_rules", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").references(() => hosts.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
triggerType: text("trigger_type").notNull(),
|
||||
thresholdValue: real("threshold_value"),
|
||||
thresholdDurationSeconds: integer("threshold_duration_seconds"),
|
||||
cooldownMinutes: integer("cooldown_minutes").notNull().default(15),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const notificationChannels = sqliteTable("notification_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
config: text("config").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const alertRuleChannels = sqliteTable("alert_rule_channels", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
channelId: integer("channel_id")
|
||||
.notNull()
|
||||
.references(() => notificationChannels.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const alertFirings = sqliteTable("alert_firings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
ruleId: integer("rule_id")
|
||||
.notNull()
|
||||
.references(() => alertRules.id, { onDelete: "cascade" }),
|
||||
hostId: integer("host_id").notNull(),
|
||||
hostName: text("host_name").notNull(),
|
||||
firedAt: text("fired_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
resolvedAt: text("resolved_at"),
|
||||
value: real("value"),
|
||||
message: text("message").notNull(),
|
||||
severity: text("severity").notNull().default("warning"),
|
||||
acknowledged: integer("acknowledged", { mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
// --- alerts end ---
|
||||
|
||||
// --- homepage begin ---
|
||||
export const homepageItems = sqliteTable("homepage_items", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
typeId: text("type_id").notNull(),
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
|
||||
layout: text("layout").notNull().default("{}"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- homepage end ---
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
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 CERTBOT_DIR = path.join(DATA_DIR, "certbot");
|
||||
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
|
||||
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
|
||||
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
|
||||
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 });
|
||||
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
||||
|
||||
const certbotDirFlags = [
|
||||
`--config-dir "${CERTBOT_CONFIG_DIR}"`,
|
||||
`--work-dir "${CERTBOT_WORK_DIR}"`,
|
||||
`--logs-dir "${CERTBOT_LOGS_DIR}"`,
|
||||
].join(" ");
|
||||
|
||||
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",
|
||||
certbotDirFlags,
|
||||
].join(" ");
|
||||
} else {
|
||||
certbotCmd = [
|
||||
"certbot",
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--webroot",
|
||||
"-w",
|
||||
`"${ACME_WEBROOT}"`,
|
||||
"-d",
|
||||
`"${domain}"`,
|
||||
"--email",
|
||||
`"${email}"`,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
certbotDirFlags,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||
domain,
|
||||
challengeType,
|
||||
operation: "acme_cert_request",
|
||||
});
|
||||
|
||||
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
|
||||
|
||||
const liveDir = path.join(CERTBOT_CONFIG_DIR, "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,660 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { getDb } from "../db/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../db/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const VALID_TRIGGER_TYPES = new Set([
|
||||
"host_offline",
|
||||
"host_online",
|
||||
"cpu_threshold",
|
||||
"memory_threshold",
|
||||
"disk_threshold",
|
||||
"health_check_failure",
|
||||
"health_check_recovery",
|
||||
"user_login",
|
||||
]);
|
||||
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// ---- Notification Channels ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* get:
|
||||
* summary: List notification channels for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of notification channels.
|
||||
*/
|
||||
router.get("/notification-channels", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM notification_channels WHERE user_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.all(userId);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list notification channels", {
|
||||
operation: "list_channels",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list channels" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels:
|
||||
* post:
|
||||
* summary: Create a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/notification-channels", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name: string;
|
||||
type: string;
|
||||
config: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (type !== "webhook" && type !== "ntfy") {
|
||||
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
}
|
||||
if (!config || typeof config !== "object") {
|
||||
return res.status(400).json({ error: "config is required" });
|
||||
}
|
||||
if (type === "ntfy") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires url" });
|
||||
if (!c.topic || typeof c.topic !== "string")
|
||||
return res.status(400).json({ error: "ntfy config requires topic" });
|
||||
}
|
||||
if (type === "webhook") {
|
||||
const c = config as Record<string, unknown>;
|
||||
if (!c.url || typeof c.url !== "string")
|
||||
return res.status(400).json({ error: "webhook config requires url" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = getDb()
|
||||
.$client.prepare(
|
||||
`INSERT INTO notification_channels (user_id, name, type, config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
name.trim(),
|
||||
type,
|
||||
JSON.stringify(config),
|
||||
enabled !== false ? 1 : 0,
|
||||
);
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
|
||||
.get(result.lastInsertRowid);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_created");
|
||||
res.status(201).json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create notification channel", {
|
||||
operation: "create_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* put:
|
||||
* summary: Update a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/notification-channels/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const { name, type, config, enabled } = req.body as {
|
||||
name?: string;
|
||||
type?: string;
|
||||
config?: unknown;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const existing = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
if (type && type !== "webhook" && type !== "ntfy") {
|
||||
return res.status(400).json({ error: "type must be 'webhook' or 'ntfy'" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(name.trim());
|
||||
}
|
||||
if (type !== undefined) {
|
||||
updates.push("type = ?");
|
||||
params.push(type);
|
||||
}
|
||||
if (config !== undefined) {
|
||||
updates.push("config = ?");
|
||||
params.push(JSON.stringify(config));
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
updates.push("enabled = ?");
|
||||
params.push(enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return res.json({ success: true });
|
||||
|
||||
params.push(channelId, userId);
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`UPDATE notification_channels SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
|
||||
)
|
||||
.run(...params);
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM notification_channels WHERE id = ?")
|
||||
.get(channelId);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_updated");
|
||||
res.json(row);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update notification channel", {
|
||||
operation: "update_channel",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update channel" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}:
|
||||
* delete:
|
||||
* summary: Delete a notification channel
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/notification-channels/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const existing = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Channel not found" });
|
||||
getDb()
|
||||
.$client.prepare("DELETE FROM notification_channels WHERE id = ?")
|
||||
.run(channelId);
|
||||
DatabaseSaveTrigger.triggerSave("notification_channel_deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /notification-channels/{id}/test:
|
||||
* post:
|
||||
* summary: Send a test notification
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post(
|
||||
"/notification-channels/:id/test",
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const channelId = Number(req.params.id);
|
||||
const row = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId) as { type: string; config: string } | undefined;
|
||||
if (!row) return res.status(404).json({ error: "Channel not found" });
|
||||
|
||||
const testPayload = {
|
||||
hostName: "Test Host",
|
||||
hostId: 0,
|
||||
triggerType: "test",
|
||||
message: "This is a test notification from Termix",
|
||||
severity: "info" as const,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: 0,
|
||||
ruleName: "Test",
|
||||
};
|
||||
|
||||
try {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(row.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid channel config" });
|
||||
}
|
||||
|
||||
if (row.type === "webhook") {
|
||||
await sendWebhook(
|
||||
config as unknown as Parameters<typeof sendWebhook>[0],
|
||||
testPayload,
|
||||
);
|
||||
} else if (row.type === "ntfy") {
|
||||
await sendNtfy(
|
||||
config as unknown as Parameters<typeof sendNtfy>[0],
|
||||
testPayload,
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Alert Rules ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* get:
|
||||
* summary: List alert rules for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-rules", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rules = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT * FROM alert_rules WHERE user_id = ? ORDER BY id ASC",
|
||||
)
|
||||
.all(userId) as Array<{ id: number }>;
|
||||
|
||||
const channelMap = new Map<number, number[]>();
|
||||
for (const rule of rules) {
|
||||
const channels = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
|
||||
)
|
||||
.all(rule.id) as Array<{ channel_id: number }>;
|
||||
channelMap.set(
|
||||
rule.id,
|
||||
channels.map((c) => c.channel_id),
|
||||
);
|
||||
}
|
||||
|
||||
const result = rules.map((r) => ({
|
||||
...r,
|
||||
channels: channelMap.get(r.id) ?? [],
|
||||
}));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert rules", {
|
||||
operation: "list_alert_rules",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert rules" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules:
|
||||
* post:
|
||||
* summary: Create an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-rules", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels = [],
|
||||
} = req.body as {
|
||||
name: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (!VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
if (thresholdValue != null && (thresholdValue < 0 || thresholdValue > 100)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdValue must be between 0 and 100" });
|
||||
}
|
||||
if (thresholdDurationSeconds != null && thresholdDurationSeconds < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "thresholdDurationSeconds must be >= 0" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = getDb()
|
||||
.$client.prepare(
|
||||
`INSERT INTO alert_rules
|
||||
(user_id, host_id, name, enabled, trigger_type, threshold_value,
|
||||
threshold_duration_seconds, cooldown_minutes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
hostId ?? null,
|
||||
name.trim(),
|
||||
enabled !== false ? 1 : 0,
|
||||
triggerType,
|
||||
thresholdValue ?? null,
|
||||
thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes ?? 15,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const ruleId = result.lastInsertRowid as number;
|
||||
|
||||
for (const channelId of channels) {
|
||||
const owned = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!owned) continue;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
|
||||
)
|
||||
.run(ruleId, channelId);
|
||||
}
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
|
||||
.get(ruleId) as Record<string, unknown>;
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_created");
|
||||
res.status(201).json({ ...row, channels });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create alert rule", {
|
||||
operation: "create_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* put:
|
||||
* summary: Update an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.put("/alert-rules/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
|
||||
const existing = getDb()
|
||||
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
|
||||
.get(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
|
||||
const {
|
||||
name,
|
||||
hostId,
|
||||
enabled,
|
||||
triggerType,
|
||||
thresholdValue,
|
||||
thresholdDurationSeconds,
|
||||
cooldownMinutes,
|
||||
channels,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
hostId?: number | null;
|
||||
enabled?: boolean;
|
||||
triggerType?: string;
|
||||
thresholdValue?: number | null;
|
||||
thresholdDurationSeconds?: number | null;
|
||||
cooldownMinutes?: number;
|
||||
channels?: number[];
|
||||
};
|
||||
|
||||
if (triggerType && !VALID_TRIGGER_TYPES.has(triggerType)) {
|
||||
return res.status(400).json({ error: "Invalid triggerType" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updates: string[] = ["updated_at = ?"];
|
||||
const params: unknown[] = [new Date().toISOString()];
|
||||
if (name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(name.trim());
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
updates.push("host_id = ?");
|
||||
params.push(hostId ?? null);
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
updates.push("enabled = ?");
|
||||
params.push(enabled ? 1 : 0);
|
||||
}
|
||||
if (triggerType !== undefined) {
|
||||
updates.push("trigger_type = ?");
|
||||
params.push(triggerType);
|
||||
}
|
||||
if (thresholdValue !== undefined) {
|
||||
updates.push("threshold_value = ?");
|
||||
params.push(thresholdValue ?? null);
|
||||
}
|
||||
if (thresholdDurationSeconds !== undefined) {
|
||||
updates.push("threshold_duration_seconds = ?");
|
||||
params.push(thresholdDurationSeconds ?? null);
|
||||
}
|
||||
if (cooldownMinutes !== undefined) {
|
||||
updates.push("cooldown_minutes = ?");
|
||||
params.push(cooldownMinutes);
|
||||
}
|
||||
params.push(ruleId, userId);
|
||||
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`UPDATE alert_rules SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`,
|
||||
)
|
||||
.run(...params);
|
||||
|
||||
if (channels !== undefined) {
|
||||
getDb()
|
||||
.$client.prepare("DELETE FROM alert_rule_channels WHERE rule_id = ?")
|
||||
.run(ruleId);
|
||||
for (const channelId of channels) {
|
||||
const owned = getDb()
|
||||
.$client.prepare(
|
||||
"SELECT id FROM notification_channels WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.get(channelId, userId);
|
||||
if (!owned) continue;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"INSERT OR IGNORE INTO alert_rule_channels (rule_id, channel_id) VALUES (?, ?)",
|
||||
)
|
||||
.run(ruleId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
const row = getDb()
|
||||
.$client.prepare("SELECT * FROM alert_rules WHERE id = ?")
|
||||
.get(ruleId) as Record<string, unknown>;
|
||||
const linkedChannels = (
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"SELECT channel_id FROM alert_rule_channels WHERE rule_id = ?",
|
||||
)
|
||||
.all(ruleId) as Array<{ channel_id: number }>
|
||||
).map((c) => c.channel_id);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_updated");
|
||||
res.json({ ...row, channels: linkedChannels });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update alert rule", {
|
||||
operation: "update_alert_rule",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update alert rule" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-rules/{id}:
|
||||
* delete:
|
||||
* summary: Delete an alert rule
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.delete("/alert-rules/:id", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const ruleId = Number(req.params.id);
|
||||
const existing = getDb()
|
||||
.$client.prepare("SELECT id FROM alert_rules WHERE id = ? AND user_id = ?")
|
||||
.get(ruleId, userId);
|
||||
if (!existing) return res.status(404).json({ error: "Alert rule not found" });
|
||||
getDb().$client.prepare("DELETE FROM alert_rules WHERE id = ?").run(ruleId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_rule_deleted");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ---- Alert Firings ----
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings:
|
||||
* get:
|
||||
* summary: List alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.get("/alert-firings", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const acknowledgedParam = req.query.acknowledged;
|
||||
|
||||
try {
|
||||
let whereClause = "af.user_id = ?";
|
||||
const params: unknown[] = [userId];
|
||||
|
||||
if (acknowledgedParam === "true") {
|
||||
whereClause += " AND af.acknowledged = 1";
|
||||
} else if (acknowledgedParam === "false") {
|
||||
whereClause += " AND af.acknowledged = 0";
|
||||
}
|
||||
|
||||
const rows = getDb()
|
||||
.$client.prepare(
|
||||
`SELECT af.*, ar.name as rule_name
|
||||
FROM alert_firings af
|
||||
LEFT JOIN alert_rules ar ON ar.id = af.rule_id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY af.fired_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, limit, offset);
|
||||
|
||||
const total = (
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
`SELECT COUNT(*) as c FROM alert_firings af WHERE ${whereClause}`,
|
||||
)
|
||||
.get(...params) as { c: number }
|
||||
).c;
|
||||
|
||||
res.json({ firings: rows, total });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list alert firings", {
|
||||
operation: "list_alert_firings",
|
||||
error: err,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list alert firings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/{id}/acknowledge:
|
||||
* post:
|
||||
* summary: Acknowledge an alert firing
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/:id/acknowledge", (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const firingId = Number(req.params.id);
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"UPDATE alert_firings SET acknowledged = 1 WHERE id = ? AND user_id = ?",
|
||||
)
|
||||
.run(firingId, userId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_firing_acknowledged");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /alert-firings/acknowledge-all:
|
||||
* post:
|
||||
* summary: Acknowledge all alert firings for the current user
|
||||
* tags:
|
||||
* - Alerts
|
||||
*/
|
||||
router.post("/alert-firings/acknowledge-all", (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
getDb()
|
||||
.$client.prepare(
|
||||
"UPDATE alert_firings SET acknowledged = 1 WHERE user_id = ?",
|
||||
)
|
||||
.run(userId);
|
||||
DatabaseSaveTrigger.triggerSave("alert_firings_acknowledged_all");
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { eq } from "drizzle-orm";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { db } from "../db/index.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
|
||||
@@ -263,97 +265,100 @@ async function deploySSHKeyToHost(
|
||||
resolve({ success: false, error: errorMessage });
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
if (
|
||||
!privateKey.includes("-----BEGIN") ||
|
||||
!privateKey.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
connectionConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
privateKey,
|
||||
hostConfig.keyPassword as string | undefined,
|
||||
);
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanKey = privateKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
} else if (hostConfig.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
connectionConfig,
|
||||
hostConfig.terminalConfig as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({ success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
} catch (keyError) {
|
||||
} else {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,6 +484,7 @@ export function registerCredentialDeployRoutes(
|
||||
password: hostData.password,
|
||||
privateKey: hostData.key,
|
||||
keyPassword: hostData.keyPassword,
|
||||
terminalConfig: hostData.terminalConfig,
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -136,8 +137,7 @@ router.post(
|
||||
.status(400)
|
||||
.json({ error: "SSH key is required for key authentication" });
|
||||
}
|
||||
const plainPassword =
|
||||
authType === "password" && password ? password : null;
|
||||
const plainPassword = password ? password : null;
|
||||
const plainKey = authType === "key" && key ? key : null;
|
||||
const plainKeyPassword =
|
||||
authType === "key" && keyPassword ? keyPassword : null;
|
||||
@@ -153,7 +153,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. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -186,6 +188,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}`,
|
||||
{
|
||||
@@ -492,10 +513,11 @@ router.put(
|
||||
if (updateData.password !== undefined) {
|
||||
updateFields.password = updateData.password || null;
|
||||
}
|
||||
const nextAuthType = updateData.authType ?? existing[0].authType;
|
||||
if (updateData.key !== undefined) {
|
||||
updateFields.key = updateData.key || null;
|
||||
|
||||
if (updateData.key && existing[0].authType === "key") {
|
||||
if (updateData.key && nextAuthType === "key") {
|
||||
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
|
||||
if (!keyInfo.success) {
|
||||
authLogger.warn("SSH key parsing failed during update", {
|
||||
@@ -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. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
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,280 @@
|
||||
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 {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "./service-link-url.js";
|
||||
import express from "express";
|
||||
|
||||
export const dashboardServiceLinksRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @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" });
|
||||
}
|
||||
const normalizedUrl = normalizeServiceLinkUrl(url);
|
||||
if (!isValidServiceLinkUrl(normalizedUrl)) {
|
||||
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: normalizedUrl,
|
||||
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" });
|
||||
}
|
||||
const normalizedUrl = isNonEmptyString(url)
|
||||
? normalizeServiceLinkUrl(url)
|
||||
: undefined;
|
||||
if (normalizedUrl !== undefined && !isValidServiceLinkUrl(normalizedUrl)) {
|
||||
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 (normalizedUrl !== undefined) updates.url = normalizedUrl;
|
||||
|
||||
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,103 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
|
||||
export const homepageFaviconRouter = express.Router();
|
||||
|
||||
// Simple LRU cache: url -> { data: Buffer, contentType: string, expires: number }
|
||||
const faviconCache = new Map<
|
||||
string,
|
||||
{ data: Buffer; contentType: string; expires: number }
|
||||
>();
|
||||
const CACHE_SIZE = 100;
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
function evictIfNeeded() {
|
||||
if (faviconCache.size >= CACHE_SIZE) {
|
||||
const oldest = faviconCache.keys().next().value;
|
||||
if (oldest) faviconCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchUrl(url: string): Promise<{ data: Buffer; contentType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: 5000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
data: Buffer.concat(chunks),
|
||||
contentType: res.headers["content-type"] || "image/x-icon",
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Favicon fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/favicon:
|
||||
* get:
|
||||
* summary: Proxy favicon fetch
|
||||
* description: Fetches and caches a site favicon server-side to avoid CORS issues.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Favicon image.
|
||||
* 400:
|
||||
* description: Invalid URL.
|
||||
* 500:
|
||||
* description: Failed to fetch favicon.
|
||||
*/
|
||||
homepageFaviconRouter.get("/", async (req: Request, res: Response) => {
|
||||
const rawUrl = req.query.url as string;
|
||||
if (!rawUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
let domain: string;
|
||||
try {
|
||||
domain = new URL(rawUrl).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = faviconCache.get(domain);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
res.setHeader("Content-Type", cached.contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
return res.send(cached.data);
|
||||
}
|
||||
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchUrl(faviconUrl);
|
||||
evictIfNeeded();
|
||||
faviconCache.set(domain, {
|
||||
data,
|
||||
contentType,
|
||||
expires: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.send(data);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Failed to fetch favicon", { domain });
|
||||
res.status(500).json({ error: "Failed to fetch favicon" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageItemsRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* get:
|
||||
* summary: Get homepage items
|
||||
* description: Returns all homepage widget items for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of homepage items.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage items.
|
||||
*/
|
||||
homepageItemsRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const items = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(eq(homepageItems.userId, userId))
|
||||
.orderBy(asc(homepageItems.id));
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage items", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage items" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items:
|
||||
* post:
|
||||
* summary: Create homepage item
|
||||
* description: Creates a new homepage widget item for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - typeId
|
||||
* properties:
|
||||
* typeId:
|
||||
* type: string
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Homepage item created.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to create homepage item.
|
||||
*/
|
||||
homepageItemsRouter.post("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { typeId, title, config } = req.body;
|
||||
|
||||
if (!typeId || typeof typeId !== "string") {
|
||||
return res.status(400).json({ error: "typeId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const [created] = await db
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
userId,
|
||||
typeId,
|
||||
title: title ?? null,
|
||||
config: config ? JSON.stringify(config) : "{}",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_created");
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to create homepage item", err);
|
||||
res.status(500).json({ error: "Failed to create homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* put:
|
||||
* summary: Update homepage item
|
||||
* description: Updates a homepage widget item.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item updated.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to update homepage item.
|
||||
*/
|
||||
homepageItemsRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const { title, config } = req.body;
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const updates: Partial<{
|
||||
title: string | null;
|
||||
config: string;
|
||||
updatedAt: string;
|
||||
}> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (title !== undefined) updates.title = title;
|
||||
if (config !== undefined) updates.config = JSON.stringify(config);
|
||||
|
||||
const [updated] = await db
|
||||
.update(homepageItems)
|
||||
.set(updates)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_updated");
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to update homepage item", err);
|
||||
res.status(500).json({ error: "Failed to update homepage item" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/items/{id}:
|
||||
* delete:
|
||||
* summary: Delete homepage item
|
||||
* description: Deletes a homepage widget item and cascades deletion to children if a folder.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Homepage item deleted.
|
||||
* 400:
|
||||
* description: Invalid id.
|
||||
* 404:
|
||||
* description: Not found.
|
||||
* 500:
|
||||
* description: Failed to delete homepage item.
|
||||
*/
|
||||
homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseInt(req.params.id as string);
|
||||
if (isNaN(id)) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)));
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("homepage_item_deleted");
|
||||
res.json({ message: "Homepage item deleted" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
res.status(500).json({ error: "Failed to delete homepage item" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { homepageLayouts } from "../db/schema.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import express from "express";
|
||||
|
||||
export const homepageLayoutRouter = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* get:
|
||||
* summary: Get homepage layout
|
||||
* description: Returns the homepage canvas layout (widget positions, pan, zoom) for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout data or null.
|
||||
* 500:
|
||||
* description: Failed to fetch homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.get("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
const parsed = JSON.parse(row.layout || "{}");
|
||||
res.json({ ...row, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to fetch homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to fetch homepage layout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/layout:
|
||||
* put:
|
||||
* summary: Save homepage layout
|
||||
* description: Saves or updates the homepage canvas layout for the authenticated user.
|
||||
* tags:
|
||||
* - Homepage
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* entries:
|
||||
* type: array
|
||||
* pan:
|
||||
* type: object
|
||||
* zoom:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 500:
|
||||
* description: Failed to save homepage layout.
|
||||
*/
|
||||
homepageLayoutRouter.put("/", async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const layoutData = req.body;
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select({ id: homepageLayouts.id })
|
||||
.from(homepageLayouts)
|
||||
.where(eq(homepageLayouts.userId, userId));
|
||||
|
||||
const layoutJson = JSON.stringify(layoutData);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing.length === 0) {
|
||||
const [created] = await db
|
||||
.insert(homepageLayouts)
|
||||
.values({ userId, layout: layoutJson, updatedAt: now })
|
||||
.returning();
|
||||
const parsed = JSON.parse(created.layout);
|
||||
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
|
||||
return res.json({ ...created, layout: parsed });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(homepageLayouts)
|
||||
.set({ layout: layoutJson, updatedAt: now })
|
||||
.where(eq(homepageLayouts.userId, userId))
|
||||
.returning();
|
||||
|
||||
const parsed = JSON.parse(updated.layout);
|
||||
DatabaseSaveTrigger.triggerSave("homepage_layout_saved");
|
||||
res.json({ ...updated, layout: parsed });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to save homepage layout", err);
|
||||
res.status(500).json({ error: "Failed to save homepage layout" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepagePingRouter = express.Router();
|
||||
|
||||
interface PingCacheEntry {
|
||||
ok: boolean;
|
||||
statusCode: number | null;
|
||||
latencyMs: number;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const pingCache = new Map<string, PingCacheEntry>();
|
||||
const CACHE_SIZE = 200;
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
function pingUrl(
|
||||
url: string,
|
||||
): Promise<{ ok: boolean; statusCode: number | null; latencyMs: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
|
||||
const done = (ok: boolean, statusCode: number | null) => {
|
||||
resolve({
|
||||
ok,
|
||||
statusCode,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
});
|
||||
};
|
||||
|
||||
const tryGet = () => {
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
done(code !== null && code < 400, code);
|
||||
});
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
};
|
||||
|
||||
const req = mod.request(
|
||||
url,
|
||||
{ method: "HEAD", timeout: FETCH_TIMEOUT_MS },
|
||||
(res) => {
|
||||
res.resume();
|
||||
const code = res.statusCode ?? null;
|
||||
if (code === 405) {
|
||||
tryGet();
|
||||
} else {
|
||||
done(code !== null && code < 400, code);
|
||||
}
|
||||
},
|
||||
);
|
||||
req.on("error", () => done(false, null));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
done(false, null);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/ping:
|
||||
* get:
|
||||
* summary: Check the HTTP reachability and latency of a URL
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ping result with ok, statusCode and latencyMs.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
*/
|
||||
homepagePingRouter.get("/", async (req: Request, res: Response) => {
|
||||
let targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 30) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = `https://${targetUrl}`;
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = pingCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json({
|
||||
ok: cached.ok,
|
||||
statusCode: cached.statusCode,
|
||||
latencyMs: cached.latencyMs,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pingUrl(targetUrl);
|
||||
if (pingCache.size >= CACHE_SIZE) {
|
||||
const oldest = pingCache.keys().next().value;
|
||||
if (oldest) pingCache.delete(oldest);
|
||||
}
|
||||
pingCache.set(targetUrl, { ...result, expires: Date.now() + ttl });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Ping failed", { targetUrl });
|
||||
res.status(500).json({ error: "Ping failed" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepageProxyRouter = express.Router();
|
||||
|
||||
interface ProxyCacheEntry {
|
||||
data: unknown;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
const proxyCache = new Map<string, ProxyCacheEntry>();
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
function fetchJson(url: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new Error("Response is not valid JSON"));
|
||||
}
|
||||
});
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/proxy:
|
||||
* get:
|
||||
* summary: Proxy a JSON API URL and return the parsed response
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: ttl
|
||||
* schema:
|
||||
* type: integer
|
||||
* description: Cache TTL in seconds (min 10, default 60)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The JSON body returned by the target URL.
|
||||
* 400:
|
||||
* description: Invalid or missing URL, or non-JSON response.
|
||||
* 500:
|
||||
* description: Failed to fetch the target URL.
|
||||
*/
|
||||
homepageProxyRouter.get("/", async (req: Request, res: Response) => {
|
||||
const targetUrl = req.query.url as string;
|
||||
const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000;
|
||||
|
||||
if (!targetUrl) return res.status(400).json({ error: "url is required" });
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = proxyCache.get(targetUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchJson(targetUrl);
|
||||
if (proxyCache.size >= CACHE_SIZE) {
|
||||
const oldest = proxyCache.keys().next().value;
|
||||
if (oldest) proxyCache.delete(oldest);
|
||||
}
|
||||
proxyCache.set(targetUrl, { data, expires: Date.now() + ttl });
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
homepageLogger.warn("Proxy fetch failed", { targetUrl, msg });
|
||||
if (msg.includes("not valid JSON")) {
|
||||
return res.status(400).json({ error: "Response is not valid JSON" });
|
||||
}
|
||||
res.status(500).json({ error: "Failed to fetch URL" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Request, Response } from "express";
|
||||
import express from "express";
|
||||
import https from "https";
|
||||
import http from "http";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
|
||||
export const homepageRssRouter = express.Router();
|
||||
|
||||
const rssCache = new Map<string, { data: RssItem[]; expires: number }>();
|
||||
const CACHE_TTL_MS = 1000 * 60 * 15; // 15 minutes
|
||||
const CACHE_SIZE = 50;
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
interface RssItem {
|
||||
title: string;
|
||||
link: string;
|
||||
pubDate: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
function fetchXml(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const mod = url.startsWith("https") ? https : http;
|
||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
res.on("error", reject);
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("RSS fetch timeout"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
const getText = (tag: string, src: string): string | null => {
|
||||
const m = new RegExp(
|
||||
`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>|<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`,
|
||||
"i",
|
||||
).exec(src);
|
||||
if (!m) return null;
|
||||
return (m[1] ?? m[2]).trim();
|
||||
};
|
||||
|
||||
const getLink = (src: string): string => {
|
||||
// Self-closing <link rel="alternate" href="..." /> (BBC style)
|
||||
const selfClose = /<link[^>]+href="([^"]+)"[^>]*\/?>/i.exec(src);
|
||||
if (selfClose) return selfClose[1];
|
||||
// Plain text <link>url</link>
|
||||
return getText("link", src) ?? "";
|
||||
};
|
||||
|
||||
while ((match = itemRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: getLink(src),
|
||||
pubDate: getText("pubDate", src) ?? getText("updated", src),
|
||||
description: getText("description", src) ?? getText("summary", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
|
||||
// Atom feed fallback
|
||||
if (items.length === 0) {
|
||||
const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
|
||||
while ((match = entryRegex.exec(xml)) !== null) {
|
||||
const src = match[1];
|
||||
const linkMatch = /<link[^>]+href="([^"]+)"/.exec(src);
|
||||
items.push({
|
||||
title: getText("title", src) ?? "(no title)",
|
||||
link: linkMatch?.[1] ?? "",
|
||||
pubDate: getText("published", src) ?? getText("updated", src),
|
||||
description: getText("summary", src) ?? getText("content", src),
|
||||
});
|
||||
if (items.length >= 50) break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /homepage/rss:
|
||||
* get:
|
||||
* summary: Proxy and parse an RSS/Atom feed
|
||||
* tags:
|
||||
* - Homepage
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: url
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: max
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 10
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of feed items.
|
||||
* 400:
|
||||
* description: Invalid or missing URL.
|
||||
* 500:
|
||||
* description: Failed to fetch or parse the feed.
|
||||
*/
|
||||
homepageRssRouter.get("/", async (req: Request, res: Response) => {
|
||||
const feedUrl = req.query.url as string;
|
||||
const max = Math.min(50, Math.max(1, Number(req.query.max) || 10));
|
||||
|
||||
if (!feedUrl) return res.status(400).json({ error: "url is required" });
|
||||
|
||||
try {
|
||||
new URL(feedUrl);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
const cached = rssCache.get(feedUrl);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return res.json(cached.data.slice(0, max));
|
||||
}
|
||||
|
||||
try {
|
||||
const xml = await fetchXml(feedUrl);
|
||||
const items = parseRss(xml);
|
||||
|
||||
if (rssCache.size >= CACHE_SIZE) {
|
||||
const oldest = rssCache.keys().next().value;
|
||||
if (oldest) rssCache.delete(oldest);
|
||||
}
|
||||
rssCache.set(feedUrl, { data: items, expires: Date.now() + CACHE_TTL_MS });
|
||||
|
||||
res.json(items.slice(0, max));
|
||||
} catch (err) {
|
||||
homepageLogger.warn("Failed to fetch RSS feed", { feedUrl });
|
||||
res.status(500).json({ error: "Failed to fetch feed" });
|
||||
}
|
||||
});
|
||||
@@ -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,97 @@ import {
|
||||
normalizeImportedHost,
|
||||
} from "./host-normalizers.js";
|
||||
|
||||
type SSHConfigHost = {
|
||||
name: string;
|
||||
hostname?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
proxyJump?: string;
|
||||
};
|
||||
|
||||
type ShareCredential = {
|
||||
alias?: unknown;
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
folder?: unknown;
|
||||
tags?: unknown;
|
||||
authType?: unknown;
|
||||
username?: unknown;
|
||||
keyType?: unknown;
|
||||
};
|
||||
|
||||
function textValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function tagString(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tag) => textValue(tag))
|
||||
.filter((tag): tag is string => !!tag)
|
||||
.join(",");
|
||||
}
|
||||
return textValue(value) || "";
|
||||
}
|
||||
|
||||
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
||||
return value === "key" ? "key" : "password";
|
||||
}
|
||||
|
||||
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 +191,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 +228,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 +259,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({
|
||||
@@ -176,7 +309,11 @@ export function registerHostBulkRoutes(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hosts: hostsToImport, overwrite } = req.body;
|
||||
const {
|
||||
hosts: hostsToImport,
|
||||
overwrite,
|
||||
credentials: credentialsToImport,
|
||||
} = req.body;
|
||||
|
||||
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||
return res
|
||||
@@ -198,6 +335,80 @@ export function registerHostBulkRoutes(
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
const credentialAliasMap = new Map<string, number>();
|
||||
const addCredentialAlias = (alias: unknown, id: number) => {
|
||||
const key = textValue(alias);
|
||||
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
||||
};
|
||||
|
||||
try {
|
||||
const existingCredentials = await SimpleDBOps.select<
|
||||
Record<string, unknown>
|
||||
>(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
for (const credential of existingCredentials) {
|
||||
addCredentialAlias(credential.name, credential.id as number);
|
||||
}
|
||||
|
||||
if (Array.isArray(credentialsToImport)) {
|
||||
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
||||
const alias = textValue(rawCredential.alias);
|
||||
const name = textValue(rawCredential.name) || alias;
|
||||
if (!alias || !name) continue;
|
||||
|
||||
const existingId = credentialAliasMap.get(name.toLowerCase());
|
||||
if (existingId) {
|
||||
addCredentialAlias(alias, existingId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created = await SimpleDBOps.insert(
|
||||
sshCredentials,
|
||||
"ssh_credentials",
|
||||
{
|
||||
userId,
|
||||
name,
|
||||
description:
|
||||
textValue(rawCredential.description) ||
|
||||
"Imported placeholder. Add the secret before connecting.",
|
||||
folder: textValue(rawCredential.folder),
|
||||
tags: tagString(rawCredential.tags),
|
||||
authType: normalizeCredentialAuthType(rawCredential.authType),
|
||||
username: textValue(rawCredential.username),
|
||||
password: null,
|
||||
key: null,
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyPassword: null,
|
||||
keyType: textValue(rawCredential.keyType),
|
||||
detectedKeyType: null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const createdCredential = created as Record<string, unknown>;
|
||||
addCredentialAlias(alias, createdCredential.id as number);
|
||||
addCredentialAlias(name, createdCredential.id as number);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(
|
||||
`Credential placeholders: ${error instanceof Error ? error.message : "failed to prepare credential aliases"}`,
|
||||
);
|
||||
}
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
if (overwrite) {
|
||||
try {
|
||||
@@ -222,6 +433,17 @@ export function registerHostBulkRoutes(
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
!hostData.credentialId &&
|
||||
hostData.credentialAlias
|
||||
) {
|
||||
hostData.credentialId = credentialAliasMap.get(
|
||||
hostData.credentialAlias.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
@@ -244,13 +466,19 @@ export function registerHostBulkRoutes(
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
!["password", "key", "credential", "none", "opkssh"].includes(
|
||||
hostData.authType,
|
||||
)
|
||||
![
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"none",
|
||||
"opkssh",
|
||||
"tailscale",
|
||||
"vault",
|
||||
].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', 'tailscale', or 'vault'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -316,6 +544,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 +574,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 +598,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 +706,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);
|
||||
@@ -85,6 +122,16 @@ describe("normalizeImportedHost", () => {
|
||||
expect(host.credentialId).toBe(7);
|
||||
expect(host.authType).toBe("credential");
|
||||
});
|
||||
|
||||
it("infers credential auth from share aliases", () => {
|
||||
const aliasHost = normalizeImportedHost({ credentialAlias: "prod-admin" });
|
||||
expect(aliasHost.credentialAlias).toBe("prod-admin");
|
||||
expect(aliasHost.authType).toBe("credential");
|
||||
|
||||
const nameHost = normalizeImportedHost({ credentialName: "ops-key" });
|
||||
expect(nameHost.credentialAlias).toBe("ops-key");
|
||||
expect(nameHost.authType).toBe("credential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSensitiveFields", () => {
|
||||
@@ -153,4 +200,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;
|
||||
}
|
||||
@@ -74,11 +94,14 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
credentialAlias?: string;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableDocker?: unknown;
|
||||
enableProxmox?: unknown;
|
||||
enableTmuxMonitor?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
@@ -91,6 +114,7 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
quickActions?: unknown;
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
proxmoxConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -115,6 +139,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
export function normalizeImportedHost(
|
||||
hostData: Record<string, unknown>,
|
||||
): NormalizedImportedHost {
|
||||
const credentialAlias =
|
||||
asString(hostData.credentialAlias) || asString(hostData.credentialName);
|
||||
const connectionType =
|
||||
asString(hostData.connectionType) ||
|
||||
(asBoolean(hostData.enableRdp)
|
||||
@@ -149,10 +175,15 @@ export function normalizeImportedHost(
|
||||
folder: asString(hostData.folder) || asString(hostData.group),
|
||||
tags: normalizeImportTags(hostData.tags),
|
||||
credentialId: asInteger(hostData.credentialId),
|
||||
credentialAlias,
|
||||
authType:
|
||||
asString(hostData.authType) ||
|
||||
asString(hostData.authMethod) ||
|
||||
(hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
|
||||
(hostData.credentialId || credentialAlias
|
||||
? "credential"
|
||||
: hostData.key
|
||||
? "key"
|
||||
: undefined),
|
||||
enableSsh:
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
@@ -214,8 +245,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 +297,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)
|
||||
: [],
|
||||
|
||||
+455
-159
@@ -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();
|
||||
|
||||
@@ -77,6 +79,69 @@ const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
type ShareCredentialExport = {
|
||||
alias: string;
|
||||
name: string;
|
||||
authType: "password" | "key";
|
||||
username: string | null;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
tags: string[];
|
||||
keyType: string | null;
|
||||
};
|
||||
|
||||
function parseJsonField(value: unknown, fallback: unknown) {
|
||||
if (!value || typeof value !== "string") return fallback;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function splitTags(value: unknown): string[] {
|
||||
if (Array.isArray(value))
|
||||
return value.filter((tag) => typeof tag === "string");
|
||||
if (typeof value !== "string") return [];
|
||||
return value.split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
function uniqueAlias(base: string, used: Set<string>): string {
|
||||
const normalized =
|
||||
base
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "credential";
|
||||
let alias = normalized;
|
||||
let suffix = 2;
|
||||
while (used.has(alias)) {
|
||||
alias = `${normalized}-${suffix++}`;
|
||||
}
|
||||
used.add(alias);
|
||||
return alias;
|
||||
}
|
||||
|
||||
function safeCredentialExport(
|
||||
credential: Record<string, unknown>,
|
||||
alias: string,
|
||||
): ShareCredentialExport {
|
||||
return {
|
||||
alias,
|
||||
name: String(credential.name || alias),
|
||||
authType: credential.authType === "key" ? "key" : "password",
|
||||
username:
|
||||
typeof credential.username === "string" ? credential.username : null,
|
||||
description:
|
||||
typeof credential.description === "string"
|
||||
? credential.description
|
||||
: null,
|
||||
folder: typeof credential.folder === "string" ? credential.folder : null,
|
||||
tags: splitTags(credential.tags),
|
||||
keyType: typeof credential.keyType === "string" ? credential.keyType : null,
|
||||
};
|
||||
}
|
||||
|
||||
registerHostInternalRoutes(router);
|
||||
|
||||
/**
|
||||
@@ -142,7 +207,9 @@ router.post(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -151,7 +218,10 @@ router.post(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -163,6 +233,7 @@ router.post(
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -179,6 +250,7 @@ router.post(
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
@@ -192,6 +264,8 @@ router.post(
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetUser,
|
||||
@@ -238,7 +312,9 @@ router.post(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -251,7 +327,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 +347,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 +373,7 @@ router.post(
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -304,6 +389,11 @@ router.post(
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
@@ -321,19 +411,6 @@ router.post(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
|
||||
sshLogger.warn("Invalid SSH key format provided", {
|
||||
operation: "host_create",
|
||||
userId,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid SSH key format. Key must be in PEM format.",
|
||||
});
|
||||
}
|
||||
|
||||
const keyValidation = parseSSHKey(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
@@ -357,6 +434,11 @@ router.post(
|
||||
sshDataObj.keyPassword = keyPassword || null;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -399,6 +481,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 +672,8 @@ router.post(
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
@@ -680,7 +783,9 @@ router.put(
|
||||
password,
|
||||
authMethod,
|
||||
authType,
|
||||
useWarpgate,
|
||||
credentialId,
|
||||
vaultProfileId,
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
@@ -689,7 +794,10 @@ router.put(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
scpLegacy,
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -701,6 +809,7 @@ router.put(
|
||||
quickActions,
|
||||
statsConfig,
|
||||
dockerConfig,
|
||||
proxmoxConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
domain,
|
||||
@@ -717,6 +826,7 @@ router.put(
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
wolBroadcastAddress,
|
||||
enableSsh,
|
||||
enableRdp,
|
||||
enableVnc,
|
||||
@@ -730,6 +840,8 @@ router.put(
|
||||
rdpDomain,
|
||||
rdpSecurity,
|
||||
rdpIgnoreCert,
|
||||
vncAuthType,
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetUser,
|
||||
@@ -773,7 +885,9 @@ router.put(
|
||||
port,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
useWarpgate: useWarpgate ? 1 : 0,
|
||||
credentialId: credentialId || null,
|
||||
vaultProfileId: vaultProfileId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
pin: pin ? 1 : 0,
|
||||
enableTerminal: enableTerminal ? 1 : 0,
|
||||
@@ -786,7 +900,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 +920,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 +946,7 @@ router.put(
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -839,6 +962,11 @@ router.put(
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
rdpIgnoreCert: rdpIgnoreCert ? 1 : 0,
|
||||
vncAuthType: enableVnc ? vncAuthType || null : null,
|
||||
vncCredentialId:
|
||||
enableVnc && vncAuthType === "credential" && vncCredentialId
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
@@ -860,20 +988,6 @@ router.put(
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "key") {
|
||||
if (key && typeof key === "string") {
|
||||
if (!key.includes("-----BEGIN") || !key.includes("-----END")) {
|
||||
sshLogger.warn("Invalid SSH key format provided", {
|
||||
operation: "host_update",
|
||||
hostId: parseInt(hostId),
|
||||
userId,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid SSH key format. Key must be in PEM format.",
|
||||
});
|
||||
}
|
||||
|
||||
const keyValidation = parseSSHKey(
|
||||
key,
|
||||
typeof keyPassword === "string" ? keyPassword : undefined,
|
||||
@@ -902,6 +1016,11 @@ router.put(
|
||||
sshDataObj.keyType = keyType;
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -909,10 +1028,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 +1063,8 @@ router.put(
|
||||
.select({
|
||||
userId: hosts.userId,
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
@@ -982,11 +1102,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 +1165,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 +1261,7 @@ router.get(
|
||||
tunnelConnections: hosts.tunnelConnections,
|
||||
jumpHosts: hosts.jumpHosts,
|
||||
enableFileManager: hosts.enableFileManager,
|
||||
scpLegacy: hosts.scpLegacy,
|
||||
defaultPath: hosts.defaultPath,
|
||||
autostartPassword: hosts.autostartPassword,
|
||||
autostartKey: hosts.autostartKey,
|
||||
@@ -1118,10 +1273,13 @@ router.get(
|
||||
createdAt: hosts.createdAt,
|
||||
updatedAt: hosts.updatedAt,
|
||||
credentialId: hosts.credentialId,
|
||||
vaultProfileId: hosts.vaultProfileId,
|
||||
overrideCredentialUsername: hosts.overrideCredentialUsername,
|
||||
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 +1297,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 +1308,14 @@ 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,
|
||||
vncAuthType: hosts.vncAuthType,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
telnetUser: hosts.telnetUser,
|
||||
@@ -1347,7 +1510,7 @@ router.get(
|
||||
* name: field
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [password, sudoPassword]
|
||||
* enum: [password, sudoPassword, vncPassword]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The requested password value.
|
||||
@@ -1363,7 +1526,7 @@ router.get(
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (!["password", "sudoPassword"].includes(field)) {
|
||||
if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
@@ -1380,7 +1543,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" });
|
||||
@@ -1490,6 +1665,8 @@ router.get(
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
rdpSecurity: resolvedHost.rdpSecurity || null,
|
||||
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
|
||||
vncAuthType: resolvedHost.vncAuthType || null,
|
||||
vncCredentialId: resolvedHost.vncCredentialId || null,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
vncPassword: resolvedHost.vncPassword || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
@@ -1509,8 +1686,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 +1713,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 +1754,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.
|
||||
@@ -1591,6 +1774,11 @@ router.get(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const shareExport =
|
||||
req.query.share === "1" ||
|
||||
req.query.share === "true" ||
|
||||
req.query.safe === "1" ||
|
||||
req.query.safe === "true";
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
@@ -1603,6 +1791,192 @@ router.get(
|
||||
userId,
|
||||
);
|
||||
|
||||
if (shareExport) {
|
||||
const credentials = await SimpleDBOps.select<Record<string, unknown>>(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId)),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
const credentialsById = new Map<number, Record<string, unknown>>();
|
||||
for (const credential of credentials) {
|
||||
if (typeof credential.id === "number") {
|
||||
credentialsById.set(credential.id, credential);
|
||||
}
|
||||
}
|
||||
|
||||
const usedAliases = new Set<string>();
|
||||
const exportedCredentials = new Map<string, ShareCredentialExport>();
|
||||
const credentialIdAliases = new Map<number, string>();
|
||||
const directCredentialAliases = new Map<string, string>();
|
||||
|
||||
const addCredential = (
|
||||
credential: Record<string, unknown>,
|
||||
fallbackName: string,
|
||||
) => {
|
||||
if (typeof credential.id === "number") {
|
||||
const existing = credentialIdAliases.get(credential.id);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const alias = uniqueAlias(
|
||||
String(credential.name || fallbackName),
|
||||
usedAliases,
|
||||
);
|
||||
exportedCredentials.set(
|
||||
alias,
|
||||
safeCredentialExport(credential, alias),
|
||||
);
|
||||
if (typeof credential.id === "number") {
|
||||
credentialIdAliases.set(credential.id, alias);
|
||||
}
|
||||
return alias;
|
||||
};
|
||||
|
||||
const addDirectCredential = (
|
||||
authType: "password" | "key",
|
||||
username: unknown,
|
||||
keyType?: unknown,
|
||||
) => {
|
||||
const usernameText =
|
||||
typeof username === "string" && username.trim()
|
||||
? username.trim()
|
||||
: "user";
|
||||
const key = `${authType}:${usernameText}:${typeof keyType === "string" ? keyType : ""}`;
|
||||
const existing = directCredentialAliases.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const alias = uniqueAlias(`${usernameText}-${authType}`, usedAliases);
|
||||
directCredentialAliases.set(key, alias);
|
||||
exportedCredentials.set(
|
||||
alias,
|
||||
safeCredentialExport(
|
||||
{
|
||||
name: `${usernameText} ${authType}`,
|
||||
authType,
|
||||
username: usernameText,
|
||||
keyType,
|
||||
},
|
||||
alias,
|
||||
),
|
||||
);
|
||||
return alias;
|
||||
};
|
||||
|
||||
const exportedHosts = allHosts.map((host) => {
|
||||
const exportedConnectionType =
|
||||
(host.connectionType as string) || "ssh";
|
||||
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
|
||||
exportedConnectionType,
|
||||
);
|
||||
|
||||
const baseExportData: Record<string, unknown> = {
|
||||
connectionType: exportedConnectionType,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: splitTags(host.tags),
|
||||
notes: host.notes || null,
|
||||
};
|
||||
|
||||
if (isRemoteDesktop) {
|
||||
return {
|
||||
...baseExportData,
|
||||
domain: host.domain || null,
|
||||
security: host.security || null,
|
||||
ignoreCert: !!host.ignoreCert,
|
||||
guacamoleConfig: parseJsonField(host.guacamoleConfig, null),
|
||||
};
|
||||
}
|
||||
|
||||
const exportData: Record<string, unknown> = {
|
||||
...baseExportData,
|
||||
authType: host.authType || "none",
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager !== false,
|
||||
enableDocker: !!host.enableDocker,
|
||||
enableProxmox: !!host.enableProxmox,
|
||||
enableTmuxMonitor: !!host.enableTmuxMonitor,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
defaultPath: host.defaultPath,
|
||||
tunnelConnections: parseJsonField(host.tunnelConnections, []),
|
||||
jumpHosts: parseJsonField(host.jumpHosts, null),
|
||||
quickActions: parseJsonField(host.quickActions, null),
|
||||
statsConfig: parseJsonField(host.statsConfig, null),
|
||||
dockerConfig: parseJsonField(host.dockerConfig, null),
|
||||
proxmoxConfig: parseJsonField(host.proxmoxConfig, null),
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
useSocks5: !!host.useSocks5,
|
||||
socks5Host: host.socks5Host || null,
|
||||
socks5Port: host.socks5Port || null,
|
||||
socks5Username: host.socks5Username || null,
|
||||
socks5ProxyChain: parseJsonField(host.socks5ProxyChain, null),
|
||||
portKnockSequence: parseJsonField(host.portKnockSequence, null),
|
||||
overrideCredentialUsername: !!host.overrideCredentialUsername,
|
||||
};
|
||||
|
||||
if (typeof host.credentialId === "number") {
|
||||
const credential = credentialsById.get(host.credentialId);
|
||||
if (credential) {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addCredential(
|
||||
credential,
|
||||
String(host.username || "credential"),
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
}
|
||||
|
||||
if (host.authType === "password") {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addDirectCredential(
|
||||
"password",
|
||||
host.username,
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
|
||||
if (host.authType === "key") {
|
||||
exportData.authType = "credential";
|
||||
exportData.credentialAlias = addDirectCredential(
|
||||
"key",
|
||||
host.username,
|
||||
host.keyType,
|
||||
);
|
||||
return exportData;
|
||||
}
|
||||
|
||||
if (host.authType === "credential") {
|
||||
exportData.authType = "none";
|
||||
}
|
||||
|
||||
return exportData;
|
||||
});
|
||||
|
||||
sshLogger.success("All hosts exported for sharing", {
|
||||
operation: "hosts_export_share",
|
||||
count: exportedHosts.length,
|
||||
credentialCount: exportedCredentials.size,
|
||||
userId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
version: "termix-host-share-v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials: Array.from(exportedCredentials.values()),
|
||||
hosts: exportedHosts,
|
||||
});
|
||||
}
|
||||
|
||||
const exportedHosts = [];
|
||||
|
||||
for (const host of allHosts) {
|
||||
@@ -1652,8 +2026,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 +2052,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 +2092,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 +2203,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 +2400,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 +2451,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 +2480,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,575 @@
|
||||
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 (authType === "agent") {
|
||||
const { applyAgentAuth } =
|
||||
await import("../../ssh/terminal-auth-helpers.js");
|
||||
const result = await applyAgentAuth(
|
||||
sshConfig,
|
||||
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
} 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,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidServiceLinkUrl,
|
||||
normalizeServiceLinkUrl,
|
||||
} from "./service-link-url.js";
|
||||
|
||||
describe("service link URL handling", () => {
|
||||
it("keeps explicit http and https URLs", () => {
|
||||
expect(normalizeServiceLinkUrl("https://example.com")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("http://192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds http to bare service addresses", () => {
|
||||
expect(normalizeServiceLinkUrl("192.168.1.10:8080")).toBe(
|
||||
"http://192.168.1.10:8080",
|
||||
);
|
||||
expect(normalizeServiceLinkUrl("termix.local")).toBe("http://termix.local");
|
||||
});
|
||||
|
||||
it("rejects unsupported schemes", () => {
|
||||
expect(isValidServiceLinkUrl("ssh://example.com")).toBe(false);
|
||||
expect(isValidServiceLinkUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@ 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";
|
||||
import { applyAgentAuth } from "../../ssh/terminal-auth-helpers.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -772,11 +774,12 @@ router.post(
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
/* eslint-disable no-async-promise-executor */
|
||||
const executePromise = new Promise<{
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}>((resolve, reject) => {
|
||||
}>(async (resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
conn.end();
|
||||
reject(new Error("Command execution timeout (30s)"));
|
||||
@@ -885,6 +888,14 @@ router.post(
|
||||
if (passphrase) {
|
||||
config.passphrase = passphrase;
|
||||
}
|
||||
} else if (authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
host.terminalConfig as Record<string, unknown> | string | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} else if (password) {
|
||||
config.password = password;
|
||||
} else if (privateKey) {
|
||||
@@ -900,6 +911,7 @@ router.post(
|
||||
|
||||
conn.connect(config);
|
||||
});
|
||||
/* eslint-enable no-async-promise-executor */
|
||||
|
||||
const result = await executePromise;
|
||||
|
||||
@@ -923,6 +935,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 +1432,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 +1566,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 +1650,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,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
generateCa,
|
||||
signUserCertificate,
|
||||
ed25519RawFromLine,
|
||||
} from "./ssh-certificate.js";
|
||||
|
||||
function publicKeyObjectFromLine(line: string) {
|
||||
const raw = ed25519RawFromLine(line);
|
||||
if (!raw) throw new Error("not ed25519");
|
||||
return crypto.createPublicKey({
|
||||
key: { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") },
|
||||
format: "jwk",
|
||||
});
|
||||
}
|
||||
|
||||
// Split a cert blob into the signed body and the raw 64-byte ed25519 signature.
|
||||
function splitCert(certLine: string): { body: Buffer; rawSig: Buffer } {
|
||||
const blob = Buffer.from(certLine.split(/\s+/)[1], "base64");
|
||||
// trailing signature string = str( str("ssh-ed25519") + str(64-byte sig) )
|
||||
const sigBlobLen = 4 + "ssh-ed25519".length + 4 + 64; // 83
|
||||
const body = blob.subarray(0, blob.length - (4 + sigBlobLen));
|
||||
const rawSig = blob.subarray(blob.length - 64);
|
||||
return { body, rawSig };
|
||||
}
|
||||
|
||||
describe("generateCa", () => {
|
||||
it("produces a valid ed25519 public line and PKCS8 private key", () => {
|
||||
const ca = generateCa();
|
||||
expect(ca.publicKeyLine.startsWith("ssh-ed25519 ")).toBe(true);
|
||||
expect(ed25519RawFromLine(ca.publicKeyLine)?.length).toBe(32);
|
||||
expect(ca.privateKeyPem).toContain("BEGIN PRIVATE KEY");
|
||||
// The PEM must load as a usable signing key.
|
||||
expect(() =>
|
||||
crypto.createPrivateKey({
|
||||
key: ca.privateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signUserCertificate", () => {
|
||||
it("returns null for non-ed25519 user keys", () => {
|
||||
const ca = generateCa();
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: "ssh-rsa AAAAB3Nz",
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "x",
|
||||
principals: [],
|
||||
validAfter: 0,
|
||||
validBefore: 1,
|
||||
});
|
||||
expect(cert).toBeNull();
|
||||
});
|
||||
|
||||
it("produces a cert whose signature verifies against the CA key", () => {
|
||||
const ca = generateCa();
|
||||
const user = generateCa(); // reuse: a valid ed25519 public line
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix:@alice",
|
||||
principals: ["root", "ubuntu"],
|
||||
validAfter: 1000,
|
||||
validBefore: 2000,
|
||||
});
|
||||
expect(cert).not.toBeNull();
|
||||
expect(cert!.startsWith("ssh-ed25519-cert-v01@openssh.com ")).toBe(true);
|
||||
|
||||
const { body, rawSig } = splitCert(cert!);
|
||||
const caPub = publicKeyObjectFromLine(ca.publicKeyLine);
|
||||
expect(crypto.verify(null, body, caPub, rawSig)).toBe(true);
|
||||
|
||||
// A different CA must NOT verify.
|
||||
const otherPub = publicKeyObjectFromLine(generateCa().publicKeyLine);
|
||||
expect(crypto.verify(null, body, otherPub, rawSig)).toBe(false);
|
||||
});
|
||||
|
||||
it("is accepted and correctly parsed by ssh-keygen -L", () => {
|
||||
let sshKeygen: string;
|
||||
try {
|
||||
sshKeygen = execFileSync("ssh-keygen", ["--help"], { encoding: "utf8" });
|
||||
void sshKeygen;
|
||||
} catch (e) {
|
||||
// ssh-keygen prints usage to stderr and exits non-zero for --help; that's
|
||||
// fine — it means the binary exists. Only skip if it's truly missing.
|
||||
if ((e as { code?: string }).code === "ENOENT") return;
|
||||
}
|
||||
|
||||
const ca = generateCa();
|
||||
const user = generateCa();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cert = signUserCertificate({
|
||||
userPublicKeyLine: user.publicKeyLine,
|
||||
caPrivateKeyPem: ca.privateKeyPem,
|
||||
caPublicKeyLine: ca.publicKeyLine,
|
||||
keyId: "termix-test-id",
|
||||
principals: ["deploy"],
|
||||
validAfter: now,
|
||||
validBefore: now + 3600,
|
||||
});
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-cert-"));
|
||||
const file = path.join(dir, "id-cert.pub");
|
||||
try {
|
||||
fs.writeFileSync(file, cert + "\n");
|
||||
const out = execFileSync("ssh-keygen", ["-L", "-f", file], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(out).toContain("user certificate");
|
||||
expect(out).toContain('Key ID: "termix-test-id"');
|
||||
expect(out).toContain("deploy");
|
||||
expect(out).toMatch(/permit-pty/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
// Minimal OpenSSH ed25519 user-certificate signer + per-user CA generation.
|
||||
// Implemented in pure Node so we never shell out or move private keys to disk.
|
||||
// Format reference: PROTOCOL.certkeys (ssh-ed25519-cert-v01@openssh.com).
|
||||
|
||||
const ED25519 = "ssh-ed25519";
|
||||
const CERT_TYPE = "ssh-ed25519-cert-v01@openssh.com";
|
||||
const SSH2_CERT_TYPE_USER = 1;
|
||||
|
||||
// Standard login extensions OpenSSH grants by default; must be name-sorted.
|
||||
const DEFAULT_EXTENSIONS = [
|
||||
"permit-X11-forwarding",
|
||||
"permit-agent-forwarding",
|
||||
"permit-port-forwarding",
|
||||
"permit-pty",
|
||||
"permit-user-rc",
|
||||
];
|
||||
|
||||
// --- SSH wire-format primitives -------------------------------------------
|
||||
|
||||
function sshString(value: Buffer | string): Buffer {
|
||||
const buf = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(buf.length, 0);
|
||||
return Buffer.concat([len, buf]);
|
||||
}
|
||||
|
||||
function sshUint32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n >>> 0, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function sshUint64(n: bigint): Buffer {
|
||||
const b = Buffer.alloc(8);
|
||||
b.writeBigUInt64BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ed25519PublicBlob(raw32: Buffer): Buffer {
|
||||
return Buffer.concat([sshString(ED25519), sshString(raw32)]);
|
||||
}
|
||||
|
||||
/** Extract the 32-byte raw ed25519 key from an `ssh-ed25519 <base64>` line. */
|
||||
export function ed25519RawFromLine(line: string): Buffer | null {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] !== ED25519 || !parts[1]) return null;
|
||||
let blob: Buffer;
|
||||
try {
|
||||
blob = Buffer.from(parts[1], "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let off = 0;
|
||||
const typeLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
if (blob.toString("utf8", off, off + typeLen) !== ED25519) return null;
|
||||
off += typeLen;
|
||||
const keyLen = blob.readUInt32BE(off);
|
||||
off += 4;
|
||||
const pk = blob.subarray(off, off + keyLen);
|
||||
return pk.length === 32 ? pk : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- CA generation ---------------------------------------------------------
|
||||
|
||||
export interface GeneratedCa {
|
||||
publicKeyLine: string; // "ssh-ed25519 <base64>"
|
||||
privateKeyPem: string; // PKCS#8 PEM (to be stored encrypted)
|
||||
}
|
||||
|
||||
export function generateCa(): GeneratedCa {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const jwk = publicKey.export({ format: "jwk" }) as { x: string };
|
||||
const raw = Buffer.from(jwk.x, "base64url");
|
||||
const publicKeyLine = `${ED25519} ${ed25519PublicBlob(raw).toString("base64")}`;
|
||||
const privateKeyPem = privateKey
|
||||
.export({ format: "pem", type: "pkcs8" })
|
||||
.toString();
|
||||
return { publicKeyLine, privateKeyPem };
|
||||
}
|
||||
|
||||
// --- Certificate signing ---------------------------------------------------
|
||||
|
||||
export interface SignCertOptions {
|
||||
userPublicKeyLine: string; // ed25519 public key to certify
|
||||
caPrivateKeyPem: string;
|
||||
caPublicKeyLine: string;
|
||||
keyId: string;
|
||||
principals: string[]; // empty = valid for all usernames
|
||||
validAfter: number; // unix seconds
|
||||
validBefore: number; // unix seconds
|
||||
serial?: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an ed25519 user public key into an OpenSSH user certificate.
|
||||
* Returns the certificate line, or null if the inputs aren't ed25519.
|
||||
*/
|
||||
export function signUserCertificate(opts: SignCertOptions): string | null {
|
||||
const pk = ed25519RawFromLine(opts.userPublicKeyLine);
|
||||
const caRaw = ed25519RawFromLine(opts.caPublicKeyLine);
|
||||
if (!pk || !caRaw) return null;
|
||||
|
||||
const signatureKey = ed25519PublicBlob(caRaw);
|
||||
const principals = Buffer.concat(opts.principals.map((p) => sshString(p)));
|
||||
const extensions = Buffer.concat(
|
||||
[...DEFAULT_EXTENSIONS]
|
||||
.sort()
|
||||
.map((name) => Buffer.concat([sshString(name), sshString("")])),
|
||||
);
|
||||
|
||||
// Everything up to (not including) the signature — this is what gets signed.
|
||||
const body = Buffer.concat([
|
||||
sshString(CERT_TYPE),
|
||||
sshString(crypto.randomBytes(32)), // nonce
|
||||
sshString(pk),
|
||||
sshUint64(opts.serial ?? 0n),
|
||||
sshUint32(SSH2_CERT_TYPE_USER),
|
||||
sshString(opts.keyId),
|
||||
sshString(principals),
|
||||
sshUint64(BigInt(opts.validAfter)),
|
||||
sshUint64(BigInt(opts.validBefore)),
|
||||
sshString(Buffer.alloc(0)), // critical options (none)
|
||||
sshString(extensions),
|
||||
sshString(Buffer.alloc(0)), // reserved
|
||||
sshString(signatureKey),
|
||||
]);
|
||||
|
||||
const caKey = crypto.createPrivateKey({
|
||||
key: opts.caPrivateKeyPem,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
});
|
||||
const rawSig = crypto.sign(null, body, caKey); // ed25519 -> 64 bytes
|
||||
const signature = Buffer.concat([sshString(ED25519), sshString(rawSig)]);
|
||||
|
||||
const cert = Buffer.concat([body, sshString(signature)]);
|
||||
return `${CERT_TYPE} ${cert.toString("base64")} ${opts.keyId}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyAlgo,
|
||||
parsePublicKey,
|
||||
matchesAlgoFilter,
|
||||
MAX_PUBLIC_KEY_LENGTH,
|
||||
} from "./termix-id-keys.js";
|
||||
|
||||
// Build a valid OpenSSH public-key line for a given type by encoding a wire
|
||||
// blob whose first string field equals the type (what parsePublicKey checks).
|
||||
function makeKey(type: string, comment = ""): string {
|
||||
const typeBuf = Buffer.from(type, "utf8");
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(typeBuf.length, 0);
|
||||
const body = Buffer.alloc(40); // arbitrary trailing key material
|
||||
const blob = Buffer.concat([header, typeBuf, body]).toString("base64");
|
||||
return `${type} ${blob}${comment ? ` ${comment}` : ""}`;
|
||||
}
|
||||
|
||||
describe("classifyAlgo", () => {
|
||||
it("maps known types to normalized groups", () => {
|
||||
expect(classifyAlgo("ssh-rsa")).toBe("RSA");
|
||||
expect(classifyAlgo("rsa-sha2-512")).toBe("RSA");
|
||||
expect(classifyAlgo("ssh-ed25519")).toBe("ED25519");
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp256")).toBe("ECDSA");
|
||||
expect(classifyAlgo("ssh-dss")).toBe("DSA");
|
||||
expect(classifyAlgo("sk-ssh-ed25519@openssh.com")).toBe("ED25519-SK");
|
||||
expect(classifyAlgo("sk-ecdsa-sha2-nistp256@openssh.com")).toBe("ECDSA-SK");
|
||||
});
|
||||
|
||||
it("falls back by substring for unknown variants", () => {
|
||||
expect(classifyAlgo("ecdsa-sha2-nistp999")).toBe("ECDSA");
|
||||
expect(classifyAlgo("rsa-sha2-256-cert")).toBe("RSA");
|
||||
expect(classifyAlgo("something-weird")).toBe("SOMETHING-WEIRD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePublicKey", () => {
|
||||
it("parses a valid ed25519 key and extracts the comment", () => {
|
||||
const parsed = parsePublicKey(makeKey("ssh-ed25519", "alice@laptop"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.type).toBe("ssh-ed25519");
|
||||
expect(parsed?.algorithm).toBe("ED25519");
|
||||
expect(parsed?.comment).toBe("alice@laptop");
|
||||
// Comment is stripped from the normalized (dedupe) form.
|
||||
expect(parsed?.normalized.includes("alice@laptop")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses SK (FIDO) key types", () => {
|
||||
expect(
|
||||
parsePublicKey(makeKey("sk-ssh-ed25519@openssh.com"))?.algorithm,
|
||||
).toBe("ED25519-SK");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null],
|
||||
[undefined],
|
||||
[""],
|
||||
[" "],
|
||||
["ssh-ed25519"], // missing blob
|
||||
["ssh-ed25519 not_base64!!"], // bad base64 charset
|
||||
["ssh-rsa AAAAB3Nz"], // blob whose embedded type != declared type
|
||||
])("rejects malformed input %p", (input) => {
|
||||
expect(parsePublicKey(input as string)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an over-length line (amplification guard)", () => {
|
||||
const valid = makeKey("ssh-ed25519");
|
||||
const padded = valid + " " + "A".repeat(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(padded.length).toBeGreaterThan(MAX_PUBLIC_KEY_LENGTH);
|
||||
expect(parsePublicKey(padded)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a blob whose embedded type does not match the prefix", () => {
|
||||
// Declared ssh-rsa but the wire blob says ssh-ed25519.
|
||||
const blob = makeKey("ssh-ed25519").split(" ")[1];
|
||||
expect(parsePublicKey(`ssh-rsa ${blob}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAlgoFilter", () => {
|
||||
it("returns all keys when no filter", () => {
|
||||
expect(matchesAlgoFilter("ED25519", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches exactly and is case-insensitive", () => {
|
||||
expect(matchesAlgoFilter("ED25519", "ed25519")).toBe(true);
|
||||
expect(matchesAlgoFilter("RSA", "RSA")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT let ED25519 match ED25519-SK (the over-match bug)", () => {
|
||||
expect(matchesAlgoFilter("ED25519-SK", "ED25519")).toBe(false);
|
||||
expect(matchesAlgoFilter("ECDSA-SK", "ECDSA")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Pure, dependency-free helpers for SSH public-key parsing/classification used
|
||||
// by the Termix ID routes. Kept separate so they can be unit-tested in isolation.
|
||||
|
||||
// Upper bound on an accepted public-key line. Public keys are tiny (an RSA-4096
|
||||
// line is ~720 chars); this caps the value the unauthenticated resolver later
|
||||
// streams, preventing a multi-MB blob being stored and amplified.
|
||||
export const MAX_PUBLIC_KEY_LENGTH = 8192;
|
||||
|
||||
// Normalized algorithm groups, used both for storage and for the `/<ALGO>`
|
||||
// resolver filter (mirrors sshid.io's RSA/ED25519/ECDSA suffixes).
|
||||
export const ALGO_GROUPS: Record<string, string> = {
|
||||
"ssh-rsa": "RSA",
|
||||
"rsa-sha2-256": "RSA",
|
||||
"rsa-sha2-512": "RSA",
|
||||
"ssh-dss": "DSA",
|
||||
"ssh-ed25519": "ED25519",
|
||||
"sk-ssh-ed25519@openssh.com": "ED25519-SK",
|
||||
"ecdsa-sha2-nistp256": "ECDSA",
|
||||
"ecdsa-sha2-nistp384": "ECDSA",
|
||||
"ecdsa-sha2-nistp521": "ECDSA",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com": "ECDSA-SK",
|
||||
};
|
||||
|
||||
export function classifyAlgo(type: string): string {
|
||||
if (ALGO_GROUPS[type]) return ALGO_GROUPS[type];
|
||||
if (type.startsWith("ecdsa-")) return "ECDSA";
|
||||
if (type.includes("ed25519")) return "ED25519";
|
||||
if (type.includes("rsa")) return "RSA";
|
||||
if (type.includes("dss") || type.includes("dsa")) return "DSA";
|
||||
return type.toUpperCase();
|
||||
}
|
||||
|
||||
export interface ParsedPublicKey {
|
||||
type: string;
|
||||
algorithm: string;
|
||||
comment: string;
|
||||
normalized: string; // "<type> <base64blob>"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a single OpenSSH public key line. Returns null when the
|
||||
* input is not a well-formed public key.
|
||||
*/
|
||||
export function parsePublicKey(
|
||||
raw: string | null | undefined,
|
||||
): ParsedPublicKey | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
if (raw.length > MAX_PUBLIC_KEY_LENGTH) return null;
|
||||
const line = raw.trim().replace(/\s+/g, " ");
|
||||
if (!line) return null;
|
||||
|
||||
const parts = line.split(" ");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
const [type, blob, ...rest] = parts;
|
||||
if (!/^[A-Za-z0-9@.-]+$/.test(type)) return null;
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(blob)) return null;
|
||||
|
||||
const decoded = Buffer.from(blob, "base64");
|
||||
// The blob is an SSH wire-format string whose first field repeats the type.
|
||||
if (decoded.length < 8) return null;
|
||||
try {
|
||||
const len = decoded.readUInt32BE(0);
|
||||
if (len <= 0 || len > 64 || 4 + len > decoded.length) return null;
|
||||
const embeddedType = decoded.toString("utf8", 4, 4 + len);
|
||||
if (embeddedType !== type) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
algorithm: classifyAlgo(type),
|
||||
comment: rest.join(" "),
|
||||
normalized: `${type} ${blob}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key's normalized algorithm group matches a `/<ALGO>` resolver
|
||||
* filter. Exact match only — `ED25519` must NOT also return `ED25519-SK`.
|
||||
*/
|
||||
export function matchesAlgoFilter(
|
||||
algorithm: string,
|
||||
filter: string | null,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
return algorithm.toUpperCase() === filter.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
const mockUpdate = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../db/index.js", () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
authLogger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = "user-1";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware:
|
||||
() => (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(),
|
||||
getRequestMeta: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getInstance: () => ({ encrypt: vi.fn(), decrypt: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-crypto.js", () => ({
|
||||
UserCrypto: { getInstance: () => ({ getUserKey: vi.fn() }) },
|
||||
}));
|
||||
|
||||
vi.mock("./termix-id-keys.js", () => ({
|
||||
termixIdKeysRouter: { use: vi.fn() },
|
||||
matchesAlgoFilter: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/simple-db-ops.js", () => ({
|
||||
SimpleDBOps: vi.fn().mockImplementation(() => ({
|
||||
findOne: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Chainable Drizzle stub — supports arbitrary method chains and resolves via .then()
|
||||
function makeChain(resolveValue: unknown) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
const methods = [
|
||||
"from",
|
||||
"where",
|
||||
"set",
|
||||
"values",
|
||||
"returning",
|
||||
"orderBy",
|
||||
"limit",
|
||||
"and",
|
||||
"eq",
|
||||
];
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
eb?: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb, eb);
|
||||
(chain as unknown as Promise<unknown>).catch = (
|
||||
cb: (e: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).catch(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
const IDENTITY_ROW = { id: 42, userId: "user-1", handle: "alice" };
|
||||
|
||||
describe("GET /termix-id/linked-credentials", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty list when user has no identity", async () => {
|
||||
// First select (getIdentityForUser) returns nothing; second should not be called
|
||||
mockSelect.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty list when identity has no keys", async () => {
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW])) // identity lookup
|
||||
.mockReturnValueOnce(makeChain([])); // keys lookup
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns deduplicated credentialIds for enabled keys", async () => {
|
||||
const keys = [
|
||||
{ credentialId: 10 },
|
||||
{ credentialId: 20 },
|
||||
{ credentialId: 10 }, // duplicate
|
||||
];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes keys with null credentialId", async () => {
|
||||
const keys = [{ credentialId: null }, { credentialId: 5 }];
|
||||
mockSelect
|
||||
.mockReturnValueOnce(makeChain([IDENTITY_ROW]))
|
||||
.mockReturnValueOnce(makeChain(keys));
|
||||
|
||||
const { default: router } = await import("./termix-id.js");
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user