mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867058bddd | |||
| 6e66a5a4ef | |||
| 07d5f5a133 | |||
| d44695eb79 | |||
| c6a2ac69dc | |||
| a3e615b59c | |||
| f2b7dae234 | |||
| d33c90dd06 | |||
| 553cba5a78 | |||
| c733673150 | |||
| f9459d6ede | |||
| ccce77ddcc | |||
| 6f6908bdf4 | |||
| 1c18620629 | |||
| 033dd38344 | |||
| 732255333f | |||
| cf743bbea5 | |||
| 4613954857 | |||
| 55867ffd7b | |||
| bc4e42f59f | |||
| 85130dc056 | |||
| 4f05bd4fbd | |||
| 6b98b86969 | |||
| 37560fa133 | |||
| aef34fd966 | |||
| 11ecb66b33 | |||
| 50a6572ac1 | |||
| 1b5baf2351 | |||
| 38c5932aa9 | |||
| 37f9402060 | |||
| b9a995c9cb | |||
| 017be33974 | |||
| 579c5c675c | |||
| d908d9dc57 | |||
| 57effd2405 | |||
| ed29b114b9 | |||
| 2f0b002212 | |||
| d8bfe5d2b4 | |||
| f586b0c3b6 | |||
| 183005de84 | |||
| 5beadffc58 | |||
| 36a0fcfd6c | |||
| acdb40bd2b | |||
| e0725848a9 |
@@ -14,6 +14,10 @@ on:
|
||||
options:
|
||||
- Development
|
||||
- Production
|
||||
source_ref:
|
||||
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||
required: false
|
||||
default: ""
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -29,6 +33,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
source_ref:
|
||||
description: "Git ref/SHA to build"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -37,8 +46,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve source revision
|
||||
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
@@ -98,7 +111,7 @@ jobs:
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
org.opencontainers.image.created=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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
|
||||
with:
|
||||
ref: badges
|
||||
|
||||
- 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/Docs/main/static/donation-snapshot.json"
|
||||
MONTHLY_GOAL=750
|
||||
|
||||
# Ethereum mainnet stablecoin contracts
|
||||
ETH_USDC="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
|
||||
ETH_USDT="0xdAC17F958D2ee523a2206206994597C13D831ec7"
|
||||
ETH_DAI="0x6B175474E89094C44Da98b954EedeAC495271d0F"
|
||||
|
||||
# Base stablecoin contracts
|
||||
BASE_USDC="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
||||
BASE_USDT="0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"
|
||||
BASE_DAI="0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb"
|
||||
|
||||
# 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)
|
||||
|
||||
# Helper: fetch an ERC-20 token balance from Blockscout token-balances endpoint
|
||||
fetch_token_balance() {
|
||||
local base_url="$1"
|
||||
local contract="$2"
|
||||
curl -sf "${base_url}/api/v2/addresses/${EVM_ADDRESS}/tokens?type=ERC-20" | CONTRACT="$contract" python3 -c "import sys, json, os; contract = os.environ['CONTRACT'].lower(); d = json.load(sys.stdin); match = next((i for i in d.get('items', []) if i.get('token', {}).get('address', '').lower() == contract), None); decimals = int((match or {}).get('token', {}).get('decimals') or 18); value = int((match or {}).get('value') or 0); print(value / (10 ** decimals) if match else 0)" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
ETH_USDC_BAL=$(fetch_token_balance "https://eth.blockscout.com" "$ETH_USDC")
|
||||
ETH_USDT_BAL=$(fetch_token_balance "https://eth.blockscout.com" "$ETH_USDT")
|
||||
ETH_DAI_BAL=$(fetch_token_balance "https://eth.blockscout.com" "$ETH_DAI")
|
||||
|
||||
BASE_USDC_BAL=$(fetch_token_balance "https://base.blockscout.com" "$BASE_USDC")
|
||||
BASE_USDT_BAL=$(fetch_token_balance "https://base.blockscout.com" "$BASE_USDT")
|
||||
BASE_DAI_BAL=$(fetch_token_balance "https://base.blockscout.com" "$BASE_DAI")
|
||||
|
||||
# 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)
|
||||
SNAP_ETH_USDC=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethUsdcBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_ETH_USDT=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethUsdtBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_ETH_DAI=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ethDaiBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_BASE_USDC=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('baseUsdcBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_BASE_USDT=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('baseUsdtBal',0))" 2>/dev/null || echo 0)
|
||||
SNAP_BASE_DAI=$(echo "$SNAPSHOT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('baseDaiBal',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
|
||||
export ETH_USDC_BAL ETH_USDT_BAL ETH_DAI_BAL BASE_USDC_BAL BASE_USDT_BAL BASE_DAI_BAL
|
||||
export SNAP_ETH_USDC SNAP_ETH_USDT SNAP_ETH_DAI SNAP_BASE_USDC SNAP_BASE_USDT SNAP_BASE_DAI
|
||||
|
||||
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))
|
||||
|
||||
eth_usdc_bal = float(os.environ.get('ETH_USDC_BAL', 0))
|
||||
eth_usdt_bal = float(os.environ.get('ETH_USDT_BAL', 0))
|
||||
eth_dai_bal = float(os.environ.get('ETH_DAI_BAL', 0))
|
||||
base_usdc_bal = float(os.environ.get('BASE_USDC_BAL', 0))
|
||||
base_usdt_bal = float(os.environ.get('BASE_USDT_BAL', 0))
|
||||
base_dai_bal = float(os.environ.get('BASE_DAI_BAL', 0))
|
||||
snap_eth_usdc = float(os.environ.get('SNAP_ETH_USDC', 0))
|
||||
snap_eth_usdt = float(os.environ.get('SNAP_ETH_USDT', 0))
|
||||
snap_eth_dai = float(os.environ.get('SNAP_ETH_DAI', 0))
|
||||
snap_base_usdc = float(os.environ.get('SNAP_BASE_USDC', 0))
|
||||
snap_base_usdt = float(os.environ.get('SNAP_BASE_USDT', 0))
|
||||
snap_base_dai = float(os.environ.get('SNAP_BASE_DAI', 0))
|
||||
|
||||
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)
|
||||
|
||||
delta_eth_usdc = max(0, eth_usdc_bal - snap_eth_usdc)
|
||||
delta_eth_usdt = max(0, eth_usdt_bal - snap_eth_usdt)
|
||||
delta_eth_dai = max(0, eth_dai_bal - snap_eth_dai)
|
||||
delta_base_usdc = max(0, base_usdc_bal - snap_base_usdc)
|
||||
delta_base_usdt = max(0, base_usdt_bal - snap_base_usdt)
|
||||
delta_base_dai = max(0, base_dai_bal - snap_base_dai)
|
||||
|
||||
total = (
|
||||
delta_eth * eth_price + delta_base * eth_price +
|
||||
delta_btc * btc_price + delta_sol * sol_price +
|
||||
delta_eth_usdc + delta_eth_usdt + delta_eth_dai +
|
||||
delta_base_usdc + delta_base_usdt + delta_base_dai
|
||||
)
|
||||
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):,}'
|
||||
|
||||
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('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 "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git add -f donation-goal.svg
|
||||
git diff --cached --stat
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: update donation goal badge"
|
||||
git push origin HEAD:badges
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
- file
|
||||
- release
|
||||
- submit
|
||||
source_ref:
|
||||
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||
required: false
|
||||
default: ""
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_type:
|
||||
@@ -38,6 +42,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
source_ref:
|
||||
description: "Git ref/SHA to build"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
outputs:
|
||||
macos_universal_dmg_sha256:
|
||||
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
||||
@@ -54,6 +63,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -144,6 +154,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -354,6 +365,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
@@ -580,6 +592,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
@@ -686,6 +699,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
@@ -823,6 +837,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Get version from package.json
|
||||
@@ -933,6 +948,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -278,7 +278,7 @@ jobs:
|
||||
|
||||
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
||||
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
||||
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
needs: [prep, merge-to-main]
|
||||
@@ -287,6 +287,7 @@ jobs:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: Production
|
||||
dry_run: ${{ inputs.mode == 'Dry run' }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
@@ -351,15 +352,17 @@ jobs:
|
||||
build_type: all
|
||||
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||
secrets: inherit
|
||||
|
||||
electron-submit:
|
||||
needs: [prep, electron-release]
|
||||
needs: [prep, merge-to-main, electron-release]
|
||||
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
artifact_destination: submit
|
||||
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||
secrets: inherit
|
||||
|
||||
cask-commit-back:
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
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="https://raw.githubusercontent.com/Termix-SSH/Termix/badges/donation-goal.svg" alt="Monthly donation goal" /></a>
|
||||
|
||||
<br />
|
||||
|
||||
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||
@@ -264,6 +266,8 @@ services:
|
||||
- termix-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -275,6 +279,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4822:4822"
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
@@ -287,6 +293,13 @@ networks:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
Termix records enabled SSH, RDP, VNC, and Telnet sessions for replay and audit.
|
||||
When using an external `guacd`, mount the same recording storage into both
|
||||
services and set `GUACD_RECORDING_PATH` to the path visible to `guacd` and
|
||||
`GUACD_RECORDING_BACKEND_PATH` to the corresponding path visible to Termix.
|
||||
Administrators can change the default 30-day retention period from Session Logs
|
||||
or with `SESSION_RECORDING_RETENTION_DAYS`.
|
||||
|
||||
<br />
|
||||
|
||||
## Donate
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:24-slim AS deps
|
||||
FROM node:26-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
|
||||
RUN npm run build:backend
|
||||
|
||||
# Stage 4: Production dependencies only
|
||||
FROM node:24-slim AS production-deps
|
||||
FROM node:26-slim AS production-deps
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Final optimized image
|
||||
FROM node:24-slim
|
||||
FROM node:26-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: development
|
||||
GUACD_HOST: "guacd-dev"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
depends_on:
|
||||
- guacd-dev
|
||||
networks:
|
||||
@@ -21,6 +23,8 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd-dev
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- termix-dev-data:/termix-data
|
||||
networks:
|
||||
- termix-dev-net
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
environment:
|
||||
PORT: "8080"
|
||||
GUACD_HOST: "guacd"
|
||||
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||
depends_on:
|
||||
- guacd
|
||||
networks:
|
||||
@@ -19,6 +20,8 @@ services:
|
||||
image: guacamole/guacd:1.6.0
|
||||
container_name: guacd
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- termix-data:/termix-data
|
||||
networks:
|
||||
- termix-net
|
||||
|
||||
|
||||
+16
-4
@@ -235,6 +235,18 @@ 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;
|
||||
@@ -433,8 +445,8 @@ 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 $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
@@ -676,8 +688,8 @@ 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 $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
+4
-4
@@ -434,8 +434,8 @@ 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 $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
@@ -677,8 +677,8 @@ 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 $proxy_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $http_host;
|
||||
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
+1
-1
@@ -1397,7 +1397,7 @@ ipcMain.handle(
|
||||
|
||||
server.once("error", fail);
|
||||
|
||||
server.listen(callbackPort, "127.0.0.1", async () => {
|
||||
server.listen(callbackPort, "localhost", async () => {
|
||||
try {
|
||||
await shell.openExternal(authUrl);
|
||||
} catch (error) {
|
||||
|
||||
Generated
+603
-570
File diff suppressed because it is too large
Load Diff
+31
-30
@@ -14,7 +14,7 @@
|
||||
"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",
|
||||
"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 && node scripts/patch-xterm-android-ime.cjs",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
@@ -45,8 +45,9 @@
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"@types/ldapjs": "^3.0.6",
|
||||
"axios": "^1.18.0",
|
||||
"axios": "^1.18.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"body-parser": "^2.3.0",
|
||||
@@ -58,28 +59,28 @@
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"jose": "^6.2.2",
|
||||
"js-yaml": "^5.0.0",
|
||||
"js-yaml": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"ldapjs": "^3.0.7",
|
||||
"motion": "^12.38.0",
|
||||
"motion": "^12.42.2",
|
||||
"multer": "^2.2.0",
|
||||
"nanoid": "^5.1.15",
|
||||
"nanoid": "^5.1.16",
|
||||
"qrcode": "^1.5.4",
|
||||
"serialport": "^13.0.0",
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^8.5.0",
|
||||
"undici": "^8.7.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.1",
|
||||
"@biomejs/biome": "2.5.2",
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@codemirror/view": "^6.43.5",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
@@ -91,23 +92,23 @@
|
||||
"@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.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-accordion": "^1.2.15",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.18",
|
||||
"@radix-ui/react-checkbox": "^1.3.6",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.18",
|
||||
"@radix-ui/react-progress": "^1.1.11",
|
||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slider": "^1.4.2",
|
||||
"@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",
|
||||
"@radix-ui/react-switch": "^1.3.2",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"@radix-ui/react-tooltip": "^1.2.11",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -130,7 +131,7 @@
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
@@ -143,7 +144,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"cytoscape": "^3.34.0",
|
||||
"electron": "^42.4.1",
|
||||
"electron": "^43.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
@@ -152,13 +153,13 @@
|
||||
"globals": "^17.5.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lucide-react": "^1.20.0",
|
||||
"prettier": "3.8.4",
|
||||
"radix-ui": "^1.6.0",
|
||||
"radix-ui": "^1.6.1",
|
||||
"react": "^19.2.7",
|
||||
"react-cytoscapejs": "^2.0.0",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -172,7 +173,7 @@
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.2",
|
||||
"sharp": "^0.35.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
|
||||
@@ -26,10 +26,31 @@ if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
||||
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') {";
|
||||
const newVersionCheck =
|
||||
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
|
||||
// Patch 1: protocol version negotiation.
|
||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||
// versions Termix can handle, and conservatively answer future 1.x versions as
|
||||
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
|
||||
// claiming support for unknown instructions.
|
||||
const oldVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
const oldPatchedVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
const newVersionBlock =
|
||||
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
|
||||
" protocolVersion = version;\n" +
|
||||
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
|
||||
" protocolVersion = '1_5_0';\n" +
|
||||
" } else {\n" +
|
||||
" protocolVersion = '1_1_0';\n" +
|
||||
" }";
|
||||
|
||||
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
|
||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||
@@ -105,17 +126,23 @@ const newReadyHandler =
|
||||
|
||||
let patched = false;
|
||||
|
||||
if (!guacdClientContent.includes(newVersionCheck)) {
|
||||
if (!guacdClientContent.includes(oldVersionCheck)) {
|
||||
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
|
||||
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldPatchedVersionBlock,
|
||||
newVersionBlock,
|
||||
);
|
||||
} else if (guacdClientContent.includes(oldVersionBlock)) {
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldVersionBlock,
|
||||
newVersionBlock,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Version check target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
guacdClientContent = guacdClientContent.replace(
|
||||
oldVersionCheck,
|
||||
newVersionCheck,
|
||||
);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
|
||||
|
||||
type PatchedGuacdClient = {
|
||||
connectionSettings: Record<string, unknown>;
|
||||
nextArgumentStreamIndex: number;
|
||||
sendInstruction: ReturnType<typeof vi.fn>;
|
||||
sendHandshakeReply: (serverHandshake: string[]) => void;
|
||||
sendRequiredArguments: (params: string[]) => void;
|
||||
};
|
||||
|
||||
function createPatchedClient(
|
||||
connectionSettings: Record<string, unknown>,
|
||||
): PatchedGuacdClient {
|
||||
return Object.assign(Object.create(GuacdClient.prototype), {
|
||||
connectionSettings,
|
||||
logger: { log: vi.fn() },
|
||||
nextArgumentStreamIndex: 0,
|
||||
sendInstruction: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("patch-guacamole-lite", () => {
|
||||
it("handles guacd dynamic argument requests", () => {
|
||||
@@ -20,4 +43,49 @@ describe("patch-guacamole-lite", () => {
|
||||
expect(content).toContain("this.sendInstruction(['blob'");
|
||||
expect(content).toContain("this.sendInstruction(['end'");
|
||||
});
|
||||
|
||||
it("keeps required-argument support when guacd offers a future 1.x protocol", () => {
|
||||
const client = createPatchedClient({
|
||||
hostname: "192.0.2.10",
|
||||
port: 5900,
|
||||
password: "secret",
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
});
|
||||
|
||||
client.sendHandshakeReply(["VERSION_1_6_0", "hostname", "port"]);
|
||||
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||
"name",
|
||||
"guacamole-lite",
|
||||
]);
|
||||
expect(client.sendInstruction).toHaveBeenCalledWith([
|
||||
"connect",
|
||||
"VERSION_1_5_0",
|
||||
"192.0.2.10",
|
||||
5900,
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers required credentials through argument value streams", () => {
|
||||
const client = createPatchedClient({
|
||||
username: "",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
client.sendRequiredArguments(["username", "password"]);
|
||||
|
||||
expect(
|
||||
client.sendInstruction.mock.calls.map(([instruction]) => instruction),
|
||||
).toEqual([
|
||||
["argv", 0, "text/plain", "username"],
|
||||
["blob", 0, ""],
|
||||
["end", 0],
|
||||
["argv", 1, "text/plain", "password"],
|
||||
["blob", 1, Buffer.from("secret", "utf8").toString("base64")],
|
||||
["end", 1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const xtermDir = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"@xterm",
|
||||
"xterm",
|
||||
"lib",
|
||||
);
|
||||
|
||||
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
|
||||
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
|
||||
// composition on the previous word and replace it with a shorter value (for
|
||||
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
|
||||
const patches = [
|
||||
{
|
||||
file: "xterm.mjs",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "xterm.js",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
],
|
||||
[
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
||||
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||
],
|
||||
[
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
|
||||
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
|
||||
],
|
||||
[
|
||||
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
|
||||
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { file, replacements } of patches) {
|
||||
const filePath = path.join(xtermDir, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(filePath, "utf8");
|
||||
if (source.includes("_preCompositionValue")) {
|
||||
console.log(`[patch-xterm-android-ime] ${file} already patched`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [original, patched] of replacements) {
|
||||
if (!source.includes(original)) {
|
||||
throw new Error(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(original, patched);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, source);
|
||||
console.log(`[patch-xterm-android-ime] Patched ${file}`);
|
||||
}
|
||||
@@ -460,6 +460,8 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
@@ -564,12 +566,30 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
||||
databaseLogger.info("Open tabs cleared on startup", {
|
||||
const timeoutRow = sqlite
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutMinutes = timeoutRow
|
||||
? parseInt(timeoutRow.value, 10)
|
||||
: 30;
|
||||
const ttlMs =
|
||||
!isNaN(timeoutMinutes) && timeoutMinutes > 0
|
||||
? timeoutMinutes * 60_000
|
||||
: 30 * 60_000;
|
||||
const cutoff = new Date(Date.now() - ttlMs).toISOString();
|
||||
const result = sqlite
|
||||
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
|
||||
.run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
databaseLogger.info("Expired open tabs cleared on startup", {
|
||||
operation: "db_init_open_tabs_cleanup",
|
||||
count: result.changes,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear open tabs on startup", {
|
||||
databaseLogger.warn("Could not clear expired open tabs on startup", {
|
||||
operation: "db_init_open_tabs_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
@@ -695,6 +715,17 @@ const addColumnIfNotExists = (
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
addColumnIfNotExists(
|
||||
"session_recordings",
|
||||
"protocol",
|
||||
"TEXT NOT NULL DEFAULT 'ssh'",
|
||||
);
|
||||
addColumnIfNotExists(
|
||||
"session_recordings",
|
||||
"format",
|
||||
"TEXT NOT NULL DEFAULT 'text'",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
@@ -747,6 +778,10 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||
|
||||
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1553,6 +1588,8 @@ const migrateSchema = () => {
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
|
||||
@@ -54,6 +54,9 @@ export const sessions = sqliteTable("sessions", {
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
oidcSub: text("oidc_sub"),
|
||||
oidcSid: text("oidc_sid"),
|
||||
ssoProviderId: integer("sso_provider_id"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -657,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
dangerousActions: text("dangerous_actions"),
|
||||
|
||||
recordingPath: text("recording_path"),
|
||||
protocol: text("protocol").notNull().default("ssh"),
|
||||
format: text("format").notNull().default("text"),
|
||||
|
||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||
.default(false),
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { db, DatabaseSaveTrigger } from "../db/index.js";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import {
|
||||
@@ -107,6 +107,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
||||
})
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to create service link", err);
|
||||
@@ -175,6 +176,7 @@ dashboardServiceLinksRouter.delete(
|
||||
),
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||
res.json({ message: "Service link deleted" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to delete service link", err);
|
||||
@@ -272,6 +274,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
||||
)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to update service link", err);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isUserDataUnlocked: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/simple-db-ops.js", () => ({
|
||||
SimpleDBOps: {
|
||||
isUserDataUnlocked: mocks.isUserDataUnlocked,
|
||||
},
|
||||
}));
|
||||
|
||||
const { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath } =
|
||||
await import("./host-enrollment-auth.js");
|
||||
|
||||
function response() {
|
||||
const json = vi.fn();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
return { status, json };
|
||||
}
|
||||
|
||||
describe("requireHostEnrollmentAccessForPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.isUserDataUnlocked.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("accepts an API key scoped to an unlocked user", () => {
|
||||
const res = response();
|
||||
const next = vi.fn();
|
||||
|
||||
requireHostEnrollmentAccessForPath(
|
||||
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(mocks.isUserDataUnlocked).toHaveBeenCalledWith("user-1");
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects regular JWT sessions", () => {
|
||||
const res = response();
|
||||
const next = vi.fn();
|
||||
|
||||
requireHostEnrollmentAccessForPath(
|
||||
{ path: "/enroll", userId: "user-1" } as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Host enrollment requires an API key",
|
||||
code: "API_KEY_REQUIRED",
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports locked encrypted data explicitly", () => {
|
||||
mocks.isUserDataUnlocked.mockReturnValue(false);
|
||||
const res = response();
|
||||
const next = vi.fn();
|
||||
|
||||
requireHostEnrollmentAccessForPath(
|
||||
{ path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(423);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "User data is locked. Sign in before enrolling hosts.",
|
||||
code: "DATA_LOCKED",
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the existing host route unchanged", () => {
|
||||
const res = response();
|
||||
const next = vi.fn();
|
||||
|
||||
requireHostEnrollmentAccessForPath(
|
||||
{ path: "/db/host", userId: "user-1" } as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(mocks.isUserDataUnlocked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHostEnrollmentDefaults", () => {
|
||||
it("creates a usable SSH host from a minimal enrollment payload", () => {
|
||||
expect(applyHostEnrollmentDefaults({ ip: "server.example" })).toEqual({
|
||||
connectionType: "ssh",
|
||||
ip: "server.example",
|
||||
port: 22,
|
||||
authType: "none",
|
||||
enableTerminal: true,
|
||||
enableSsh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit enrollment settings", () => {
|
||||
expect(
|
||||
applyHostEnrollmentDefaults({
|
||||
ip: "server.example",
|
||||
port: 2222,
|
||||
authType: "password",
|
||||
enableTerminal: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
port: 2222,
|
||||
authType: "password",
|
||||
enableTerminal: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
|
||||
export function applyHostEnrollmentDefaults(
|
||||
hostData: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
connectionType: "ssh",
|
||||
port: 22,
|
||||
authType: "none",
|
||||
enableTerminal: true,
|
||||
enableSsh: true,
|
||||
...hostData,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireHostEnrollmentAccessForPath(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (req.path !== "/enroll") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (!authReq.apiKeyId) {
|
||||
res.status(401).json({
|
||||
error: "Host enrollment requires an API key",
|
||||
code: "API_KEY_REQUIRED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(authReq.userId)) {
|
||||
res.status(423).json({
|
||||
error: "User data is locked. Sign in before enrolling hosts.",
|
||||
code: "DATA_LOCKED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -25,7 +25,10 @@ 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 {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "../../ssh/credential-username.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
@@ -40,6 +43,10 @@ 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 {
|
||||
applyHostEnrollmentDefaults,
|
||||
requireHostEnrollmentAccessForPath,
|
||||
} from "./host-enrollment-auth.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -161,9 +168,10 @@ registerHostInternalRoutes(router);
|
||||
* description: Failed to save SSH data.
|
||||
*/
|
||||
router.post(
|
||||
"/db/host",
|
||||
["/db/host", "/enroll"],
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
requireHostEnrollmentAccessForPath,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -196,6 +204,10 @@ router.post(
|
||||
hostData = req.body;
|
||||
}
|
||||
|
||||
if (req.path === "/enroll") {
|
||||
hostData = applyHostEnrollmentDefaults(hostData);
|
||||
}
|
||||
|
||||
const {
|
||||
connectionType,
|
||||
name,
|
||||
@@ -259,6 +271,8 @@ router.post(
|
||||
rdpPort,
|
||||
vncPort,
|
||||
telnetPort,
|
||||
rdpAuthType,
|
||||
rdpCredentialId,
|
||||
rdpUser,
|
||||
rdpPassword,
|
||||
rdpDomain,
|
||||
@@ -268,6 +282,8 @@ router.post(
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetAuthType,
|
||||
telnetCredentialId,
|
||||
telnetUser,
|
||||
telnetPassword,
|
||||
} = hostData;
|
||||
@@ -385,6 +401,11 @@ router.post(
|
||||
rdpPort: rdpPort || 3389,
|
||||
vncPort: vncPort || 5900,
|
||||
telnetPort: telnetPort || 23,
|
||||
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||
rdpCredentialId:
|
||||
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||
? rdpCredentialId
|
||||
: null,
|
||||
rdpUser: rdpUser || null,
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
@@ -395,6 +416,11 @@ router.post(
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||
telnetCredentialId:
|
||||
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||
? telnetCredentialId
|
||||
: null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
|
||||
@@ -433,7 +459,12 @@ router.post(
|
||||
sshDataObj.key = key || null;
|
||||
sshDataObj.keyPassword = keyPassword || null;
|
||||
sshDataObj.keyType = keyType;
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.password = password || null;
|
||||
} else if (effectiveAuthType === "credential") {
|
||||
sshDataObj.password = password || null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -520,6 +551,67 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/enroll:
|
||||
* post:
|
||||
* summary: Enroll a host with an API key
|
||||
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
|
||||
* tags:
|
||||
* - Host Enrollment
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [ip]
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* ip:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 65535
|
||||
* default: 22
|
||||
* username:
|
||||
* type: string
|
||||
* authType:
|
||||
* type: string
|
||||
* enum: [none, password, key, credential, agent]
|
||||
* default: none
|
||||
* password:
|
||||
* type: string
|
||||
* folder:
|
||||
* type: string
|
||||
* tags:
|
||||
* oneOf:
|
||||
* - type: string
|
||||
* - type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enableTerminal:
|
||||
* type: boolean
|
||||
* enableFileManager:
|
||||
* type: boolean
|
||||
* enableTunnel:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host enrolled successfully.
|
||||
* 400:
|
||||
* description: Invalid host data.
|
||||
* 401:
|
||||
* description: Missing or invalid API key.
|
||||
* 423:
|
||||
* description: The API key user's encrypted data is locked.
|
||||
* 500:
|
||||
* description: Failed to enroll the host.
|
||||
*/
|
||||
/**
|
||||
* @openapi
|
||||
* /host/quick-connect:
|
||||
@@ -642,7 +734,7 @@ router.post(
|
||||
|
||||
const cred = credentials[0];
|
||||
|
||||
resolvedPassword = cred.password as string | undefined;
|
||||
resolvedPassword = pickResolvedPassword(password, cred.password);
|
||||
resolvedKey = cred.privateKey as string | undefined;
|
||||
resolvedKeyPassword = cred.keyPassword as string | undefined;
|
||||
resolvedKeyType = cred.keyType as string | undefined;
|
||||
@@ -835,6 +927,8 @@ router.put(
|
||||
rdpPort,
|
||||
vncPort,
|
||||
telnetPort,
|
||||
rdpAuthType,
|
||||
rdpCredentialId,
|
||||
rdpUser,
|
||||
rdpPassword,
|
||||
rdpDomain,
|
||||
@@ -844,6 +938,8 @@ router.put(
|
||||
vncCredentialId,
|
||||
vncPassword,
|
||||
vncUser,
|
||||
telnetAuthType,
|
||||
telnetCredentialId,
|
||||
telnetUser,
|
||||
telnetPassword,
|
||||
} = hostData;
|
||||
@@ -958,6 +1054,11 @@ router.put(
|
||||
rdpPort: rdpPort || 3389,
|
||||
vncPort: vncPort || 5900,
|
||||
telnetPort: telnetPort || 23,
|
||||
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||
rdpCredentialId:
|
||||
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||
? rdpCredentialId
|
||||
: null,
|
||||
rdpUser: rdpUser || null,
|
||||
rdpDomain: rdpDomain || null,
|
||||
rdpSecurity: rdpSecurity || null,
|
||||
@@ -968,6 +1069,11 @@ router.put(
|
||||
? vncCredentialId
|
||||
: null,
|
||||
vncUser: vncUser || null,
|
||||
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||
telnetCredentialId:
|
||||
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||
? telnetCredentialId
|
||||
: null,
|
||||
telnetUser: telnetUser || null,
|
||||
};
|
||||
|
||||
@@ -1015,7 +1121,12 @@ router.put(
|
||||
if (keyType) {
|
||||
sshDataObj.keyType = keyType;
|
||||
}
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.password = password || null;
|
||||
} else if (effectiveAuthType === "credential") {
|
||||
sshDataObj.password = password || null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else if (effectiveAuthType === "agent") {
|
||||
sshDataObj.password = null;
|
||||
sshDataObj.key = null;
|
||||
@@ -1065,6 +1176,7 @@ router.put(
|
||||
credentialId: hosts.credentialId,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
authType: hosts.authType,
|
||||
})
|
||||
.from(hosts)
|
||||
@@ -1115,12 +1227,20 @@ router.put(
|
||||
sshDataObj.vncCredentialId !== undefined
|
||||
? sshDataObj.vncCredentialId
|
||||
: hostRecord[0].vncCredentialId;
|
||||
const newTelnetCredId =
|
||||
sshDataObj.telnetCredentialId !== undefined
|
||||
? sshDataObj.telnetCredentialId
|
||||
: hostRecord[0].telnetCredentialId;
|
||||
const hadCredential =
|
||||
hostRecord[0].credentialId !== null ||
|
||||
hostRecord[0].rdpCredentialId !== null ||
|
||||
hostRecord[0].vncCredentialId !== null;
|
||||
hostRecord[0].vncCredentialId !== null ||
|
||||
hostRecord[0].telnetCredentialId !== null;
|
||||
const willHaveCredential =
|
||||
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
|
||||
newCredId !== null ||
|
||||
newRdpCredId !== null ||
|
||||
newVncCredId !== null ||
|
||||
newTelnetCredId !== null;
|
||||
if (hadCredential && !willHaveCredential) {
|
||||
await db
|
||||
.delete(hostAccess)
|
||||
@@ -1308,6 +1428,7 @@ router.get(
|
||||
rdpPort: hosts.rdpPort,
|
||||
vncPort: hosts.vncPort,
|
||||
telnetPort: hosts.telnetPort,
|
||||
rdpAuthType: hosts.rdpAuthType,
|
||||
rdpCredentialId: hosts.rdpCredentialId,
|
||||
rdpUser: hosts.rdpUser,
|
||||
rdpPassword: hosts.rdpPassword,
|
||||
@@ -1318,6 +1439,8 @@ router.get(
|
||||
vncCredentialId: hosts.vncCredentialId,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
telnetAuthType: hosts.telnetAuthType,
|
||||
telnetCredentialId: hosts.telnetCredentialId,
|
||||
telnetUser: hosts.telnetUser,
|
||||
telnetPassword: hosts.telnetPassword,
|
||||
|
||||
@@ -1660,6 +1783,8 @@ router.get(
|
||||
rdpPort: resolvedHost.rdpPort || 3389,
|
||||
vncPort: resolvedHost.vncPort || 5900,
|
||||
telnetPort: resolvedHost.telnetPort || 23,
|
||||
rdpAuthType: resolvedHost.rdpAuthType || null,
|
||||
rdpCredentialId: resolvedHost.rdpCredentialId || null,
|
||||
rdpUser: resolvedHost.rdpUser || null,
|
||||
rdpPassword: resolvedHost.rdpPassword || null,
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
@@ -1669,6 +1794,8 @@ router.get(
|
||||
vncCredentialId: resolvedHost.vncCredentialId || null,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
vncPassword: resolvedHost.vncPassword || null,
|
||||
telnetAuthType: resolvedHost.telnetAuthType || null,
|
||||
telnetCredentialId: resolvedHost.telnetCredentialId || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
telnetPassword: resolvedHost.telnetPassword || null,
|
||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||
@@ -2445,7 +2572,7 @@ async function resolveHostCredentials(
|
||||
const credential = credentials[0];
|
||||
const resolvedHost: Record<string, unknown> = {
|
||||
...host,
|
||||
password: credential.password,
|
||||
password: pickResolvedPassword(host.password, credential.password),
|
||||
key: credential.key,
|
||||
keyPassword: credential.keyPassword,
|
||||
keyType: credential.keyType,
|
||||
|
||||
@@ -2,24 +2,58 @@ 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 { eq, desc, lt } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { sessionRecordings, hosts } from "../db/schema.js";
|
||||
import { sessionRecordings, hosts, users } from "../db/schema.js";
|
||||
import { apiLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
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;
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
function isAllowedRecordingPath(filePath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
return ["session_logs", "session_recordings"].some((directory) => {
|
||||
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
|
||||
return resolved.startsWith(base);
|
||||
});
|
||||
}
|
||||
|
||||
function getRetentionDays(): number {
|
||||
const envDays = parseInt(
|
||||
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
|
||||
10,
|
||||
);
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'session_recording_retention_days'",
|
||||
)
|
||||
.get() as { value?: string } | undefined;
|
||||
const configured = parseInt(row?.value || "", 10);
|
||||
if (configured >= 1 && configured <= 3650) return configured;
|
||||
} catch {
|
||||
// use environment/default below
|
||||
}
|
||||
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
|
||||
}
|
||||
|
||||
async function canAccessRecording(
|
||||
userId: string,
|
||||
ownerId: string,
|
||||
): Promise<boolean> {
|
||||
return userId === ownerId || permissionManager.isAdmin(userId);
|
||||
}
|
||||
|
||||
async function pruneOldLogs(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(
|
||||
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const old = await db
|
||||
@@ -33,8 +67,7 @@ async function pruneOldLogs(): Promise<void> {
|
||||
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)) {
|
||||
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
|
||||
await fs.promises.unlink(resolved).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -81,6 +114,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const isAdmin = await permissionManager.isAdmin(userId);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: sessionRecordings.id,
|
||||
@@ -90,12 +124,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
endedAt: sessionRecordings.endedAt,
|
||||
duration: sessionRecordings.duration,
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
protocol: sessionRecordings.protocol,
|
||||
format: sessionRecordings.format,
|
||||
username: users.username,
|
||||
hostName: hosts.name,
|
||||
hostIp: hosts.ip,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
||||
.where(eq(sessionRecordings.userId, userId))
|
||||
.leftJoin(users, eq(sessionRecordings.userId, users.id))
|
||||
.where(isAdmin ? undefined : eq(sessionRecordings.userId, userId))
|
||||
.orderBy(desc(sessionRecordings.startedAt));
|
||||
|
||||
const records = rows.map((row) => {
|
||||
@@ -120,6 +158,46 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/retention",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
res.json({ retentionDays: getRetentionDays() });
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/retention",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!(await permissionManager.isAdmin(userId))) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
const retentionDays = Number(req.body?.retentionDays);
|
||||
if (
|
||||
!Number.isInteger(retentionDays) ||
|
||||
retentionDays < 1 ||
|
||||
retentionDays > 3650
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Retention must be between 1 and 3650 days" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
)
|
||||
.run("session_recording_retention_days", String(retentionDays));
|
||||
void pruneOldLogs();
|
||||
res.json({ retentionDays });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session_logs/{id}:
|
||||
@@ -153,12 +231,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
||||
if (!(await canAccessRecording(userId, rows[0].userId))) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
res.json({ log: rows[0] });
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to fetch session log", error, {
|
||||
@@ -206,26 +285,27 @@ router.get(
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||
.select({
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
userId: sessionRecordings.userId,
|
||||
format: sessionRecordings.format,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionRecordings.id, id),
|
||||
eq(sessionRecordings.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0)
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
if (!(await canAccessRecording(userId, rows[0].userId))) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (!isAllowedRecordingPath(resolvedPath)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
@@ -233,8 +313,14 @@ router.get(
|
||||
return res.status(404).json({ error: "File not found" });
|
||||
}
|
||||
|
||||
const content = await fs.promises.readFile(resolvedPath, "utf-8");
|
||||
res.type("text/plain").send(content);
|
||||
const content = await fs.promises.readFile(resolvedPath);
|
||||
const contentType =
|
||||
rows[0].format === "guacamole"
|
||||
? "application/vnd.apache.guacamole.recording"
|
||||
: rows[0].format === "asciicast"
|
||||
? "application/x-asciicast"
|
||||
: "text/plain";
|
||||
res.type(contentType).send(content);
|
||||
} catch (error) {
|
||||
apiLogger.error("Failed to read session log content", error, {
|
||||
operation: "session_log_content",
|
||||
@@ -277,27 +363,26 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||
.select({
|
||||
recordingPath: sessionRecordings.recordingPath,
|
||||
userId: sessionRecordings.userId,
|
||||
})
|
||||
.from(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
)
|
||||
.where(eq(sessionRecordings.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
||||
if (!(await canAccessRecording(userId, rows[0].userId))) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const filePath = rows[0].recordingPath;
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(
|
||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
||||
);
|
||||
await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
|
||||
|
||||
if (filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
||||
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
|
||||
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
|
||||
await fs.promises.unlink(resolvedPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,15 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
|
||||
await import("./user-oidc-utils.js");
|
||||
const {
|
||||
isOIDCUserAllowed,
|
||||
getOIDCConfigFromEnv,
|
||||
extractOidcGroups,
|
||||
validateLogoutTokenClaims,
|
||||
} = await import("./user-oidc-utils.js");
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
@@ -199,3 +206,48 @@ describe("extractOidcGroups", () => {
|
||||
expect(extractOidcGroups({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLogoutTokenClaims", () => {
|
||||
const validClaims = {
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
iat: 1_783_641_600,
|
||||
jti: "logout-1",
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
|
||||
};
|
||||
|
||||
it("accepts a spec-compliant back-channel logout payload", () => {
|
||||
expect(validateLogoutTokenClaims(validClaims)).toEqual({
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
jti: "logout-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the logout event to contain an object", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({
|
||||
...validClaims,
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
|
||||
}),
|
||||
).toThrow("missing back-channel logout event");
|
||||
});
|
||||
|
||||
it("requires iat and jti claims", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
|
||||
).toThrow("missing iat claim");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
|
||||
).toThrow("missing jti claim");
|
||||
});
|
||||
|
||||
it("rejects nonce and requires sub or sid", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
|
||||
).toThrow("must not contain a nonce");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
|
||||
).toThrow("must contain sub and/or sid");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,13 @@ import { eq } from "drizzle-orm";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { Agent } from "undici";
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
function normalizeIssuer(url: string): string {
|
||||
return url.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export type OIDCConfig = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
@@ -417,3 +424,102 @@ export async function loadProviderConfig(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const target = normalizeIssuer(issuer);
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true));
|
||||
for (const row of rows) {
|
||||
if (!["oidc", "github", "google"].includes(row.type)) continue;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
parsed = decryptConfigSecret(parsed);
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
|
||||
return { config, providerType, providerDbId: row.id };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to resolve SSO provider by issuer", err, {
|
||||
issuer,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type LogoutTokenClaims = {
|
||||
sub: string | null;
|
||||
sid: string | null;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export function validateLogoutTokenClaims(
|
||||
payload: Record<string, unknown>,
|
||||
): LogoutTokenClaims {
|
||||
if ("nonce" in payload) {
|
||||
throw new Error("logout_token must not contain a nonce claim");
|
||||
}
|
||||
|
||||
const event = (payload.events as Record<string, unknown> | undefined)?.[
|
||||
BACKCHANNEL_LOGOUT_EVENT
|
||||
];
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
throw new Error("logout_token missing back-channel logout event");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.iat)) {
|
||||
throw new Error("logout_token missing iat claim");
|
||||
}
|
||||
|
||||
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
|
||||
if (!jti) {
|
||||
throw new Error("logout_token missing jti claim");
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
||||
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
||||
if (!sub && !sid) {
|
||||
throw new Error("logout_token must contain sub and/or sid");
|
||||
}
|
||||
|
||||
return { sub, sid, jti };
|
||||
}
|
||||
|
||||
export async function validateLogoutToken(
|
||||
logoutToken: string,
|
||||
config: OIDCConfig,
|
||||
): Promise<LogoutTokenClaims> {
|
||||
const payload = await verifyOIDCToken(
|
||||
logoutToken,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
config.ca_cert,
|
||||
);
|
||||
|
||||
return validateLogoutTokenClaims(payload);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
getRequestBasePath,
|
||||
getRequestBaseUrlWithForceHTTPS,
|
||||
} from "../../utils/request-origin.js";
|
||||
import {
|
||||
getDesktopOidcCallbackUrl,
|
||||
isOidcTokenCallback,
|
||||
} from "../../utils/oidc-desktop-callback.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
@@ -26,6 +30,8 @@ import {
|
||||
extractOidcGroups,
|
||||
loadProviderConfig,
|
||||
buildFetchOptions,
|
||||
resolveProviderByIssuer,
|
||||
validateLogoutToken,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
@@ -652,7 +658,10 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin;
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
|
||||
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
||||
if (!frontendOrigin) {
|
||||
return res.status(400).json({ error: "Invalid desktop callback port" });
|
||||
}
|
||||
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
@@ -999,9 +1008,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
const ghRedirectUrl = new URL(frontendOrigin);
|
||||
ghRedirectUrl.searchParams.set("success", "true");
|
||||
const ghIsTokenCallback =
|
||||
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
||||
frontendOrigin.startsWith("termix-mobile:");
|
||||
const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin);
|
||||
const ghMaxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1464,10 +1471,16 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
"oidc_role_shared_credentials",
|
||||
);
|
||||
|
||||
const oidcSub = typeof userInfo.sub === "string" ? userInfo.sub : null;
|
||||
const oidcSid = typeof userInfo.sid === "string" ? userInfo.sid : null;
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
rememberMe: storedRememberMe,
|
||||
oidcSub,
|
||||
oidcSid,
|
||||
ssoProviderId: callbackProviderDbId ?? null,
|
||||
});
|
||||
|
||||
authLogger.success("OIDC login successful", {
|
||||
@@ -1479,9 +1492,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
const isTokenCallback =
|
||||
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
||||
frontendOrigin.startsWith("termix-mobile:");
|
||||
const isTokenCallback = isOidcTokenCallback(frontendOrigin);
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
@@ -1788,6 +1799,84 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const seenLogoutJti = new Map<string, number>();
|
||||
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function pruneLogoutJti(now: number): void {
|
||||
for (const [key, expiry] of seenLogoutJti) {
|
||||
if (expiry <= now) seenLogoutJti.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isReplayedJti(jti: string): boolean {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
return seenLogoutJti.has(jti);
|
||||
}
|
||||
|
||||
function markLogoutJti(jti: string): void {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS);
|
||||
}
|
||||
|
||||
router.post("/oidc/backchannel-logout", async (req, res) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const logoutToken = (req.body as Record<string, unknown> | undefined)
|
||||
?.logout_token;
|
||||
if (typeof logoutToken !== "string" || !logoutToken) {
|
||||
return res.status(400).json({ error: "missing logout_token" });
|
||||
}
|
||||
|
||||
let issuer: string | null = null;
|
||||
try {
|
||||
const parts = logoutToken.split(".");
|
||||
if (parts.length === 3) {
|
||||
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
||||
issuer = typeof claims.iss === "string" ? claims.iss : null;
|
||||
}
|
||||
} catch {
|
||||
issuer = null;
|
||||
}
|
||||
|
||||
if (!issuer) {
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
|
||||
const provider = await resolveProviderByIssuer(issuer);
|
||||
if (!provider) {
|
||||
authLogger.warn("Back-channel logout for unknown issuer", { issuer });
|
||||
return res.status(400).json({ error: "unknown issuer" });
|
||||
}
|
||||
|
||||
const claims = await validateLogoutToken(logoutToken, provider.config);
|
||||
|
||||
if (claims.jti && isReplayedJti(claims.jti)) {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: provider.providerDbId,
|
||||
sub: claims.sub,
|
||||
sid: claims.sid,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel session revocation failed", err);
|
||||
return res.status(500).json({ error: "logout processing failed" });
|
||||
}
|
||||
|
||||
markLogoutJti(claims.jti);
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel logout failed", err);
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
|
||||
@@ -3,6 +3,10 @@ import { guacLogger } from "../utils/logger.js";
|
||||
import { GuacamoleTokenService } from "./token-service.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { sessionRecordings } from "../database/db/schema.js";
|
||||
import type { GuacamoleRecordingMetadata } from "./token-service.js";
|
||||
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
@@ -21,6 +25,63 @@ function readGuacdOptions(): { host: string; port: number } {
|
||||
}
|
||||
|
||||
const GUAC_WS_PORT = 30008;
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const GUACAMOLE_RECORDINGS_DIR =
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
};
|
||||
};
|
||||
|
||||
async function persistGuacamoleRecording(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
const recording = clientConnection.connectionSettings?.recording;
|
||||
if (!recording) return;
|
||||
|
||||
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
|
||||
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
|
||||
if (!resolvedPath.startsWith(allowedBase)) return;
|
||||
|
||||
// guacd may flush/rename the recording just after the websocket closes.
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 10 && !fs.existsSync(resolvedPath);
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
guacLogger.warn("Guacamole recording file was not found", {
|
||||
operation: "guac_recording_missing",
|
||||
hostId: recording.hostId,
|
||||
path: resolvedPath,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const endedAt = new Date();
|
||||
const startedAt = new Date(recording.startedAt);
|
||||
await getDb()
|
||||
.insert(sessionRecordings)
|
||||
.values({
|
||||
hostId: recording.hostId,
|
||||
userId: recording.userId,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
duration: Math.max(
|
||||
0,
|
||||
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
),
|
||||
recordingPath: resolvedPath,
|
||||
protocol: recording.protocol,
|
||||
format: "guacamole",
|
||||
});
|
||||
}
|
||||
|
||||
const websocketOptions = {
|
||||
port: GUAC_WS_PORT,
|
||||
@@ -89,35 +150,31 @@ function createGuacServer(): GuacamoleLite {
|
||||
clientOptions,
|
||||
);
|
||||
|
||||
server.on(
|
||||
"open",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection opened", {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"close",
|
||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
guacLogger.info("Guacamole connection closed", {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
"error",
|
||||
(
|
||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||
error: Error,
|
||||
) => {
|
||||
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||
guacLogger.error("Guacamole connection error", error, {
|
||||
operation: "guac_connection_error",
|
||||
type: clientConnection.connectionSettings?.type,
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,12 +9,15 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { Client } from "ssh2";
|
||||
import net from "net";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
|
||||
router.use(authManager.createAuthMiddleware());
|
||||
|
||||
@@ -526,6 +529,28 @@ router.post(
|
||||
? { guacdPort: perConnectionGuacdPort }
|
||||
: {}),
|
||||
};
|
||||
const recordingEnabled = host.enableSessionLogging !== false;
|
||||
const recordingName = `${crypto.randomUUID()}.guac`;
|
||||
const recordingPath =
|
||||
process.env.GUACD_RECORDING_PATH ||
|
||||
process.env.GUACD_RECORDING_BACKEND_PATH ||
|
||||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
|
||||
const recordingMetadata = recordingEnabled
|
||||
? {
|
||||
hostId,
|
||||
userId,
|
||||
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||
path: recordingName,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
if (recordingEnabled) {
|
||||
guacConfig["recording-path"] = recordingPath;
|
||||
guacConfig["recording-name"] = recordingName;
|
||||
guacConfig["create-recording-path"] = true;
|
||||
guacConfig["recording-exclude-output"] = false;
|
||||
guacConfig["recording-include-keys"] = true;
|
||||
}
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
@@ -533,7 +558,11 @@ router.post(
|
||||
guacConfig["drive-path"] = "/drive";
|
||||
guacConfig["create-drive-path"] = true;
|
||||
}
|
||||
token = tokenService.createRdpToken(hostname, username, password, {
|
||||
token = tokenService.createRdpToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
domain,
|
||||
security:
|
||||
@@ -548,7 +577,9 @@ router.post(
|
||||
: true,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
token = tokenService.createVncToken(
|
||||
@@ -561,14 +592,21 @@ router.post(
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||
token = tokenService.createTelnetToken(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
{
|
||||
port,
|
||||
...guacConfig,
|
||||
...guacdOverrides,
|
||||
});
|
||||
},
|
||||
recordingMetadata,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
|
||||
@@ -45,4 +45,23 @@ describe("GuacamoleTokenService", () => {
|
||||
});
|
||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves recording metadata outside guacd connection settings", () => {
|
||||
const recording = {
|
||||
hostId: 42,
|
||||
userId: "user-1",
|
||||
protocol: "vnc" as const,
|
||||
path: "recording.guac",
|
||||
startedAt: "2026-07-14T00:00:00.000Z",
|
||||
};
|
||||
const token = tokenService.createVncToken(
|
||||
"vnc.example.test",
|
||||
"user",
|
||||
"secret",
|
||||
{},
|
||||
recording,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,15 @@ export interface GuacamoleConnectionSettings {
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
path: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const CIPHER = "aes-256-cbc";
|
||||
@@ -127,6 +136,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -144,6 +154,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -156,6 +167,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -171,6 +183,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -183,6 +196,7 @@ export class GuacamoleTokenService {
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -198,6 +212,7 @@ export class GuacamoleTokenService {
|
||||
...settingsOptions,
|
||||
},
|
||||
},
|
||||
recording,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
pickResolvedPassword,
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
|
||||
@@ -30,6 +31,27 @@ describe("pickResolvedUsername", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickResolvedPassword", () => {
|
||||
it("keeps the host-specific password ahead of the credential password", () => {
|
||||
expect(pickResolvedPassword("host-pass", "credential-pass")).toBe(
|
||||
"host-pass",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the credential password when the host has none", () => {
|
||||
expect(pickResolvedPassword("", "credential-pass")).toBe("credential-pass");
|
||||
expect(pickResolvedPassword(undefined, "credential-pass")).toBe(
|
||||
"credential-pass",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats whitespace-only passwords as empty", () => {
|
||||
expect(pickResolvedPassword(" ", "credential-pass")).toBe(
|
||||
"credential-pass",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandOidcUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -21,6 +21,19 @@ export function pickResolvedUsername(
|
||||
return cred;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host can keep a per-host password while using a shared key credential.
|
||||
* Prefer that host-specific value and fall back to the credential password.
|
||||
*/
|
||||
export function pickResolvedPassword(
|
||||
hostPassword: unknown,
|
||||
credentialPassword: unknown,
|
||||
): string | undefined {
|
||||
if (isNonEmptyString(hostPassword)) return hostPassword;
|
||||
if (isNonEmptyString(credentialPassword)) return credentialPassword;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
@@ -276,6 +277,10 @@ async function createJumpHostChain(
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
@@ -489,6 +494,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getRuntimeLabel,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
@@ -1617,6 +1618,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
client.connect(config);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
|
||||
|
||||
describe("buildDeleteCommand", () => {
|
||||
it("builds a PowerShell 5.1 compatible delete command for Windows files", () => {
|
||||
const command = buildDeleteCommand(
|
||||
"/C:/Users/Administrator/test.txt",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Users\\Administrator\\test.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
);
|
||||
expect(command.commandWithSuccess).not.toContain("&&");
|
||||
});
|
||||
|
||||
it("adds recursive deletion for Windows directories", () => {
|
||||
const command = buildDeleteCommand("C:/Temp/Folder", true);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\Folder' -Recurse -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes single quotes in Windows literal paths", () => {
|
||||
const command = buildDeleteCommand("/C:/Temp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe(
|
||||
"Remove-Item -LiteralPath 'C:\\Temp\\O''Brien.txt' -Force -ErrorAction Stop",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps POSIX delete commands using shell success chaining", () => {
|
||||
const command = buildDeleteCommand("/tmp/O'Brien.txt", false);
|
||||
|
||||
expect(command.command).toBe("rm -f '/tmp/O'\"'\"'Brien.txt'");
|
||||
expect(command.commandWithSuccess).toBe(
|
||||
`${command.command} && echo "SUCCESS"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||
|
||||
export interface DeleteCommand {
|
||||
command: string;
|
||||
commandWithSuccess: string;
|
||||
}
|
||||
|
||||
function quotePosixPath(path: string): string {
|
||||
return `'${path.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function quotePowerShellLiteral(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function buildDeleteCommand(
|
||||
itemPath: string,
|
||||
isDirectory: boolean,
|
||||
): DeleteCommand {
|
||||
if (isWindowsSftpPath(itemPath)) {
|
||||
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
|
||||
const command = isDirectory
|
||||
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
|
||||
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
|
||||
};
|
||||
}
|
||||
|
||||
const path = quotePosixPath(itemPath);
|
||||
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
|
||||
|
||||
return {
|
||||
command,
|
||||
commandWithSuccess: `${command} && echo "SUCCESS"`,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
execWithSudo,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
import { isWindowsSftpPath } from "./transfer-paths.js";
|
||||
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
@@ -375,22 +375,10 @@ export function registerFileOperationRoutes(
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const isWindowsPath = isWindowsSftpPath(itemPath);
|
||||
let deleteCommand: string;
|
||||
if (isWindowsPath) {
|
||||
const winPath = itemPath
|
||||
.replace(/\//g, "\\")
|
||||
.replace(/^\\([A-Za-z]:\\)/, "$1");
|
||||
const escapedWinPath = winPath.replace(/"/g, '""');
|
||||
deleteCommand = isDirectory
|
||||
? `rd /s /q "${escapedWinPath}"`
|
||||
: `del /f /q "${escapedWinPath}"`;
|
||||
} else {
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}'`
|
||||
: `rm -f '${escapedPath}'`;
|
||||
}
|
||||
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
||||
itemPath,
|
||||
Boolean(isDirectory),
|
||||
);
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -421,10 +409,7 @@ export function registerFileOperationRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
execChannel(
|
||||
sshConn,
|
||||
`${deleteCommand} && echo "SUCCESS"`,
|
||||
(err, stream) => {
|
||||
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH deleteItem error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -502,8 +487,7 @@ export function registerFileOperationRoutes(
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./file-manager-session.js";
|
||||
import { registerFileListingRoutes } from "./file-manager-list-routes.js";
|
||||
import { registerFileOperationRoutes } from "./file-manager-operation-routes.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
import { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
|
||||
import { registerFileActionRoutes } from "./file-manager-action-routes.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
@@ -194,6 +195,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
await resolveSshConnectConfigHost(config);
|
||||
|
||||
const authType = host.authType;
|
||||
|
||||
@@ -1759,6 +1761,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
client.connect(config);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
createConnectionLog,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "./host-metrics-helpers.js";
|
||||
|
||||
describe("supportsMetrics", () => {
|
||||
@@ -66,3 +68,57 @@ describe("createConnectionLog", () => {
|
||||
expect("timestamp" in entry).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tcpPingThroughJumpHost", () => {
|
||||
it("reports the final destination online when forwarding succeeds", async () => {
|
||||
const stream = { destroy: vi.fn() };
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
|
||||
expect(host).toBe("private.example");
|
||||
expect(port).toBe(22);
|
||||
callback(undefined, stream);
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(true);
|
||||
expect(stream.destroy).toHaveBeenCalledOnce();
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding fails", async () => {
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
|
||||
callback(new Error("Connection refused"));
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
const result = tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
"private.example",
|
||||
22,
|
||||
5000,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
@@ -21,6 +22,32 @@ export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
}
|
||||
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createConnectionLog(
|
||||
type: "info" | "success" | "warning" | "error",
|
||||
stage: ConnectionStage,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConcurrentLimiter, HostPollCache } from "./host-metrics-state.js";
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
let peak = 0;
|
||||
let current = 0;
|
||||
|
||||
const job = async () => {
|
||||
current += 1;
|
||||
peak = Math.max(peak, current);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
current -= 1;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
]);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("runs waiters in FIFO order after a slot frees", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
const order: number[] = [];
|
||||
|
||||
const first = limiter.run(async () => {
|
||||
order.push(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
const third = limiter.run(async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostPollCache", () => {
|
||||
it("returns cached host within TTL for the same user", () => {
|
||||
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
|
||||
cache.set(1, "user-a", { id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
|
||||
expect(cache.get(1, "user-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("expires entries after TTL", () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new HostPollCache<{ id: number }>(1_000);
|
||||
cache.set(7, "u", { id: 7 });
|
||||
expect(cache.get(7, "u")).toEqual({ id: 7 });
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(cache.get(7, "u")).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invalidate drops a host or the whole cache", () => {
|
||||
const cache = new HostPollCache<{ id: number }>(60_000);
|
||||
cache.set(1, "u", { id: 1 });
|
||||
cache.set(2, "u", { id: 2 });
|
||||
cache.invalidate(1);
|
||||
expect(cache.get(1, "u")).toBeNull();
|
||||
expect(cache.get(2, "u")).toEqual({ id: 2 });
|
||||
cache.invalidate();
|
||||
expect(cache.get(2, "u")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -250,6 +250,88 @@ class PollingBackoff {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
||||
* Used to stop host-metrics status/metrics polls from stampeding under load.
|
||||
*/
|
||||
export class ConcurrentLimiter {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly maxConcurrent: number) {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.waiters.length;
|
||||
}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
const next = this.waiters.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
|
||||
export class HostPollCache<THost extends { id: number } = { id: number }> {
|
||||
private cache = new Map<
|
||||
number,
|
||||
{ host: THost; userId: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(private readonly ttlMs = 30_000) {}
|
||||
|
||||
get(hostId: number, userId: string): THost | null {
|
||||
const entry = this.cache.get(hostId);
|
||||
if (!entry) return null;
|
||||
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
|
||||
this.cache.delete(hostId);
|
||||
return null;
|
||||
}
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
set(hostId: number, userId: string, host: THost): void {
|
||||
this.cache.set(hostId, {
|
||||
host,
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
this.cache.clear();
|
||||
return;
|
||||
}
|
||||
this.cache.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/** TCP status checks are cheap relative to SSH metrics collection. */
|
||||
export const statusPollLimiter = new ConcurrentLimiter(20);
|
||||
/** SSH metrics execs are expensive; keep concurrency tight. */
|
||||
export const metricsPollLimiter = new ConcurrentLimiter(5);
|
||||
export const hostPollCache = new HostPollCache(30_000);
|
||||
|
||||
export const requestQueue = new RequestQueue();
|
||||
export const metricsCache = new MetricsCache();
|
||||
export const authFailureTracker = new AuthFailureTracker();
|
||||
|
||||
+186
-27
@@ -4,7 +4,10 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { pickResolvedUsername } from "./credential-username.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "./credential-username.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -38,6 +41,7 @@ import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences
|
||||
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
|
||||
import { AlertEngine } from "./alert-engine.js";
|
||||
import { registerManagerRoutes } from "./managers/index.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
import { AccessDeniedError } from "./managers/route-helpers.js";
|
||||
import type { ManagerHost } from "./managers/types.js";
|
||||
import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
|
||||
@@ -45,6 +49,7 @@ import {
|
||||
createConnectionLog,
|
||||
isTcpPingEnabled,
|
||||
supportsMetrics,
|
||||
tcpPingThroughJumpHost,
|
||||
} from "./host-metrics-helpers.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
import {
|
||||
@@ -57,9 +62,12 @@ import {
|
||||
} from "./host-metrics-sessions.js";
|
||||
import {
|
||||
authFailureTracker,
|
||||
hostPollCache,
|
||||
metricsCache,
|
||||
metricsPollLimiter,
|
||||
pollingBackoff,
|
||||
requestQueue,
|
||||
statusPollLimiter,
|
||||
} from "./host-metrics-state.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -104,6 +112,7 @@ interface SSHHostWithCredentials {
|
||||
rdpPort?: number;
|
||||
vncPort?: number;
|
||||
telnetPort?: number;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
}
|
||||
|
||||
type StatusEntry = {
|
||||
@@ -163,6 +172,9 @@ class PollingManager {
|
||||
private activeViewers = new Map<number, Set<string>>();
|
||||
private viewerDetails = new Map<string, MetricsViewer>();
|
||||
private viewerCleanupInterval: NodeJS.Timeout;
|
||||
/** Skip stacking another status/metrics poll while one is already running. */
|
||||
private statusInFlight = new Set<number>();
|
||||
private metricsInFlight = new Set<number>();
|
||||
|
||||
constructor() {
|
||||
this.viewerCleanupInterval = setInterval(() => {
|
||||
@@ -170,6 +182,81 @@ class PollingManager {
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
/** Spread timers so N hosts do not fire on the same wall-clock second. */
|
||||
private intervalWithJitter(intervalMs: number, hostId: number): number {
|
||||
const spread = Math.min(intervalMs * 0.2, 15_000);
|
||||
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
|
||||
return intervalMs + jitter;
|
||||
}
|
||||
|
||||
private async resolveHostForPoll(
|
||||
host: SSHHostWithCredentials,
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | null> {
|
||||
const cached = hostPollCache.get(
|
||||
host.id,
|
||||
userId,
|
||||
) as SSHHostWithCredentials | null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const refreshed = await fetchHostById(host.id, userId);
|
||||
if (!refreshed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hostPollCache.set(host.id, userId, refreshed);
|
||||
const config = this.pollingConfigs.get(host.id);
|
||||
if (config) {
|
||||
config.host = refreshed;
|
||||
config.statsConfig = this.parseStatsConfig(refreshed.statsConfig);
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
private scheduleStatusPoll(
|
||||
host: SSHHostWithCredentials,
|
||||
viewerUserId?: string,
|
||||
): void {
|
||||
if (this.statusInFlight.has(host.id)) {
|
||||
return;
|
||||
}
|
||||
this.statusInFlight.add(host.id);
|
||||
void statusPollLimiter
|
||||
.run(() => this.pollHostStatus(host, viewerUserId))
|
||||
.catch((err) => {
|
||||
statsLogger.error("Status polling failed", err, {
|
||||
operation: "status_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.statusInFlight.delete(host.id);
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleMetricsPoll(
|
||||
host: SSHHostWithCredentials,
|
||||
viewerUserId?: string,
|
||||
): void {
|
||||
if (this.metricsInFlight.has(host.id)) {
|
||||
return;
|
||||
}
|
||||
this.metricsInFlight.add(host.id);
|
||||
void metricsPollLimiter
|
||||
.run(() => this.pollHostMetrics(host, viewerUserId))
|
||||
.catch((err) => {
|
||||
statsLogger.error("Metrics polling failed", err, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.metricsInFlight.delete(host.id);
|
||||
});
|
||||
}
|
||||
|
||||
private getGlobalDefaults(): {
|
||||
statusCheckInterval: number;
|
||||
metricsInterval: number;
|
||||
@@ -303,14 +390,17 @@ class PollingManager {
|
||||
this.pollingConfigs.set(host.id, config);
|
||||
|
||||
if (isTcpPingEnabled(statsConfig)) {
|
||||
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||
const intervalMs = this.intervalWithJitter(
|
||||
statsConfig.statusCheckInterval * 1000,
|
||||
host.id,
|
||||
);
|
||||
|
||||
this.pollHostStatus(host, viewerUserId);
|
||||
this.scheduleStatusPoll(host, viewerUserId);
|
||||
|
||||
config.statusTimer = setInterval(() => {
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
|
||||
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
||||
this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -318,9 +408,27 @@ class PollingManager {
|
||||
}
|
||||
|
||||
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||
const intervalMs = this.intervalWithJitter(
|
||||
statsConfig.metricsInterval * 1000,
|
||||
host.id,
|
||||
);
|
||||
|
||||
await this.pollHostMetrics(host, viewerUserId);
|
||||
// First sample still awaited (gated) so callers can rely on a warm cache.
|
||||
if (!this.metricsInFlight.has(host.id)) {
|
||||
this.metricsInFlight.add(host.id);
|
||||
try {
|
||||
await metricsPollLimiter.run(() =>
|
||||
this.pollHostMetrics(host, viewerUserId),
|
||||
);
|
||||
} catch (err) {
|
||||
statsLogger.error("Metrics polling failed", err, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
} finally {
|
||||
this.metricsInFlight.delete(host.id);
|
||||
}
|
||||
}
|
||||
|
||||
config.metricsTimer = setInterval(() => {
|
||||
const latestConfig = this.pollingConfigs.get(host.id);
|
||||
@@ -329,15 +437,10 @@ class PollingManager {
|
||||
latestConfig.statsConfig.metricsEnabled &&
|
||||
supportsMetrics(latestConfig.host)
|
||||
) {
|
||||
this.pollHostMetrics(
|
||||
this.scheduleMetricsPoll(
|
||||
latestConfig.host,
|
||||
latestConfig.viewerUserId,
|
||||
).catch((err) => {
|
||||
statsLogger.error("Metrics polling failed", err, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
});
|
||||
);
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -350,30 +453,51 @@ class PollingManager {
|
||||
viewerUserId?: string,
|
||||
): Promise<void> {
|
||||
const userId = viewerUserId || host.userId;
|
||||
const refreshedHost = await fetchHostById(host.id, userId);
|
||||
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
||||
if (!refreshedHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let pingHost = refreshedHost.ip;
|
||||
const ct = refreshedHost.connectionType || "ssh";
|
||||
let pingPort: number;
|
||||
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
|
||||
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
|
||||
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
|
||||
else pingPort = refreshedHost.port;
|
||||
|
||||
let isOnline: boolean;
|
||||
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
||||
const firstJump = await fetchHostById(
|
||||
refreshedHost.jumpHosts[0].hostId,
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
refreshedHost.useSocks5 &&
|
||||
(refreshedHost.socks5Host ||
|
||||
(refreshedHost.socks5ProxyChain &&
|
||||
refreshedHost.socks5ProxyChain.length > 0))
|
||||
? {
|
||||
useSocks5: true,
|
||||
socks5Host: refreshedHost.socks5Host,
|
||||
socks5Port: refreshedHost.socks5Port,
|
||||
socks5Username: refreshedHost.socks5Username,
|
||||
socks5Password: refreshedHost.socks5Password,
|
||||
socks5ProxyChain: refreshedHost.socks5ProxyChain,
|
||||
}
|
||||
: null;
|
||||
const jumpClient = await createJumpHostChain(
|
||||
refreshedHost.jumpHosts,
|
||||
userId,
|
||||
proxyConfig,
|
||||
);
|
||||
if (firstJump) {
|
||||
pingHost = firstJump.ip;
|
||||
pingPort = firstJump.port;
|
||||
isOnline = jumpClient
|
||||
? await tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
refreshedHost.ip,
|
||||
pingPort,
|
||||
5000,
|
||||
)
|
||||
: false;
|
||||
} else {
|
||||
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
|
||||
}
|
||||
}
|
||||
const isOnline = await tcpPing(pingHost, pingPort, 5000);
|
||||
const statusEntry: StatusEntry = {
|
||||
status: isOnline ? "online" : "offline",
|
||||
lastChecked: new Date().toISOString(),
|
||||
@@ -399,7 +523,7 @@ class PollingManager {
|
||||
viewerUserId?: string,
|
||||
): Promise<void> {
|
||||
const userId = viewerUserId || host.userId;
|
||||
const refreshedHost = await fetchHostById(host.id, userId);
|
||||
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
||||
if (!refreshedHost) {
|
||||
return;
|
||||
}
|
||||
@@ -554,6 +678,9 @@ class PollingManager {
|
||||
this.metricsStore.delete(hostId);
|
||||
}
|
||||
}
|
||||
hostPollCache.invalidate(hostId);
|
||||
this.statusInFlight.delete(hostId);
|
||||
this.metricsInFlight.delete(hostId);
|
||||
}
|
||||
|
||||
stopMetricsOnly(hostId: number): void {
|
||||
@@ -960,9 +1087,10 @@ async function resolveHostCredentials(
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
baseHost.password = pickResolvedPassword(
|
||||
host.password,
|
||||
credential.password,
|
||||
);
|
||||
if (
|
||||
credential.key ||
|
||||
(credential as Record<string, unknown>).privateKey
|
||||
@@ -1128,6 +1256,8 @@ async function buildSshConfig(
|
||||
// no credentials needed
|
||||
} else if (host.authType === "opkssh") {
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "vault") {
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "credential") {
|
||||
if (host.password) {
|
||||
base.password = host.password;
|
||||
@@ -1186,6 +1316,10 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
} else if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("./vault-ssh-connect.js");
|
||||
await setupVaultSshSignerAuth(config, client, host);
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
@@ -1331,8 +1465,18 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
} else if (config.sock) {
|
||||
client.connect(config);
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1799,6 +1943,7 @@ app.post("/host-updated", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
hostPollCache.invalidate(hostId);
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
connectionPool.clearKeyConnections(getPoolKey(host));
|
||||
@@ -2070,6 +2215,10 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
} else if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("./vault-ssh-connect.js");
|
||||
await setupVaultSshSignerAuth(config, client, host);
|
||||
}
|
||||
|
||||
const connectionPromise = new Promise<{
|
||||
@@ -2357,7 +2506,17 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
@@ -190,7 +191,7 @@ export async function resolveHostById(
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.password = pickResolvedPassword(host.password, cred.password);
|
||||
// Prefer the normalised private key; fall back to raw key field
|
||||
host.key = (cred.privateKey || cred.key) as string | null;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import yaml from "js-yaml";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
|
||||
const AUTH_TIMEOUT = 60 * 1000;
|
||||
|
||||
@@ -131,7 +131,7 @@ async function checkOPKConfigExists(): Promise<{
|
||||
|
||||
let providers: ProviderRedirectInfo[] = [];
|
||||
try {
|
||||
const parsed = yaml.load(content) as {
|
||||
const parsed = loadYaml(content) as {
|
||||
providers?: Array<{
|
||||
alias?: string;
|
||||
issuer?: string;
|
||||
|
||||
@@ -8,18 +8,29 @@ interface PooledConnection {
|
||||
hostKey: string;
|
||||
}
|
||||
|
||||
interface ConnectionWaiter {
|
||||
resolve: (client: Client) => void;
|
||||
reject: (error: Error) => void;
|
||||
factory: () => Promise<Client>;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3;
|
||||
const DEFAULT_MAX_WAIT_MS = 30_000;
|
||||
const IDLE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
|
||||
|
||||
class SSHConnectionPool {
|
||||
private connections = new Map<string, PooledConnection[]>();
|
||||
private maxConnectionsPerHost = 3;
|
||||
private waiters = new Map<string, ConnectionWaiter[]>();
|
||||
private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST;
|
||||
private maxWaitMs = DEFAULT_MAX_WAIT_MS;
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor() {
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private isConnectionHealthy(client: Client): boolean {
|
||||
@@ -38,34 +49,30 @@ class SSHConnectionPool {
|
||||
}
|
||||
}
|
||||
|
||||
async getConnection(
|
||||
private removeUnhealthy(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
): Promise<Client> {
|
||||
let connections = this.connections.get(key) || [];
|
||||
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
if (!this.isConnectionHealthy(available.client)) {
|
||||
connections: PooledConnection[],
|
||||
target: PooledConnection,
|
||||
): PooledConnection[] {
|
||||
sshLogger.warn("Removing unhealthy connection from pool", {
|
||||
operation: "pool_remove_dead",
|
||||
hostKey: key,
|
||||
});
|
||||
try {
|
||||
available.client.end();
|
||||
target.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
connections = connections.filter((c) => c !== available);
|
||||
this.connections.set(key, connections);
|
||||
} else {
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
return available.client;
|
||||
}
|
||||
const filtered = connections.filter((c) => c !== target);
|
||||
this.connections.set(key, filtered);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
private async createPooledClient(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
existing: PooledConnection[],
|
||||
): Promise<Client> {
|
||||
const client = await factory();
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
@@ -73,8 +80,8 @@ class SSHConnectionPool {
|
||||
inUse: true,
|
||||
hostKey: key,
|
||||
};
|
||||
connections.push(pooled);
|
||||
this.connections.set(key, connections);
|
||||
existing.push(pooled);
|
||||
this.connections.set(key, existing);
|
||||
|
||||
client.on("end", () => {
|
||||
this.removeConnection(key, client);
|
||||
@@ -86,51 +93,113 @@ class SSHConnectionPool {
|
||||
return client;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const checkAvailable = () => {
|
||||
const conns = this.connections.get(key) || [];
|
||||
const avail = conns.find((conn) => !conn.inUse);
|
||||
if (avail) {
|
||||
if (!this.isConnectionHealthy(avail.client)) {
|
||||
try {
|
||||
avail.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
private enqueueWaiter(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
): Promise<Client> {
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const queue = this.waiters.get(key);
|
||||
if (queue) {
|
||||
const idx = queue.findIndex((w) => w.timer === timer);
|
||||
if (idx >= 0) queue.splice(idx, 1);
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
}
|
||||
const filtered = conns.filter((c) => c !== avail);
|
||||
this.connections.set(key, filtered);
|
||||
factory().then((client) => {
|
||||
const pooled: PooledConnection = {
|
||||
client,
|
||||
lastUsed: Date.now(),
|
||||
inUse: true,
|
||||
const err = new Error(
|
||||
`SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
|
||||
);
|
||||
sshLogger.warn("Connection pool wait timeout", {
|
||||
operation: "pool_wait_timeout",
|
||||
hostKey: key,
|
||||
};
|
||||
filtered.push(pooled);
|
||||
this.connections.set(key, filtered);
|
||||
client.on("end", () => this.removeConnection(key, client));
|
||||
client.on("close", () => this.removeConnection(key, client));
|
||||
resolve(client);
|
||||
maxWaitMs: this.maxWaitMs,
|
||||
});
|
||||
} else {
|
||||
avail.inUse = true;
|
||||
avail.lastUsed = Date.now();
|
||||
resolve(avail.client);
|
||||
}
|
||||
} else {
|
||||
setTimeout(checkAvailable, 100);
|
||||
}
|
||||
};
|
||||
checkAvailable();
|
||||
reject(err);
|
||||
}, this.maxWaitMs);
|
||||
|
||||
const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
|
||||
const queue = this.waiters.get(key) || [];
|
||||
queue.push(waiter);
|
||||
this.waiters.set(key, queue);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hand a free connection (or new slot) to the next waiter, if any. */
|
||||
private wakeWaiter(key: string): void {
|
||||
const queue = this.waiters.get(key);
|
||||
if (!queue || queue.length === 0) return;
|
||||
|
||||
let connections = this.connections.get(key) || [];
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
|
||||
if (available) {
|
||||
if (!this.isConnectionHealthy(available.client)) {
|
||||
connections = this.removeUnhealthy(key, connections, available);
|
||||
// Fall through — may create or wait again.
|
||||
} else {
|
||||
const waiter = queue.shift()!;
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
else this.waiters.set(key, queue);
|
||||
clearTimeout(waiter.timer);
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
waiter.resolve(available.client);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
const waiter = queue.shift()!;
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
else this.waiters.set(key, queue);
|
||||
clearTimeout(waiter.timer);
|
||||
this.createPooledClient(key, waiter.factory, connections)
|
||||
.then(waiter.resolve)
|
||||
.catch(waiter.reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private rejectWaiters(key: string, reason: string): void {
|
||||
const queue = this.waiters.get(key);
|
||||
if (!queue) return;
|
||||
this.waiters.delete(key);
|
||||
for (const waiter of queue) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
}
|
||||
|
||||
async getConnection(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
): Promise<Client> {
|
||||
let connections = this.connections.get(key) || [];
|
||||
|
||||
const available = connections.find((conn) => !conn.inUse);
|
||||
if (available) {
|
||||
if (!this.isConnectionHealthy(available.client)) {
|
||||
connections = this.removeUnhealthy(key, connections, available);
|
||||
} else {
|
||||
available.inUse = true;
|
||||
available.lastUsed = Date.now();
|
||||
return available.client;
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.length < this.maxConnectionsPerHost) {
|
||||
return this.createPooledClient(key, factory, connections);
|
||||
}
|
||||
|
||||
return this.enqueueWaiter(key, factory);
|
||||
}
|
||||
|
||||
releaseConnection(key: string, client: Client): void {
|
||||
const connections = this.connections.get(key) || [];
|
||||
const pooled = connections.find((conn) => conn.client === client);
|
||||
if (pooled) {
|
||||
pooled.inUse = false;
|
||||
pooled.lastUsed = Date.now();
|
||||
this.wakeWaiter(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +212,8 @@ class SSHConnectionPool {
|
||||
} else {
|
||||
this.connections.set(key, filtered);
|
||||
}
|
||||
// A slot or idle connection may now be free for waiters.
|
||||
this.wakeWaiter(key);
|
||||
}
|
||||
|
||||
clearKeyConnections(key: string): void {
|
||||
@@ -155,15 +226,15 @@ class SSHConnectionPool {
|
||||
}
|
||||
}
|
||||
this.connections.delete(key);
|
||||
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const maxAge = 10 * 60 * 1000;
|
||||
|
||||
for (const [hostKey, connections] of this.connections.entries()) {
|
||||
const activeConnections = connections.filter((conn) => {
|
||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||
if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
@@ -187,10 +258,14 @@ class SSHConnectionPool {
|
||||
} else {
|
||||
this.connections.set(hostKey, activeConnections);
|
||||
}
|
||||
this.wakeWaiter(hostKey);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllConnections(): void {
|
||||
for (const key of [...this.waiters.keys()]) {
|
||||
this.rejectWaiters(key, "SSH connection pool destroyed");
|
||||
}
|
||||
for (const connections of this.connections.values()) {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isRetriableDnsError,
|
||||
resolveHostForSshConnect,
|
||||
resolveSshConnectConfigHost,
|
||||
shouldResolveBeforeSshConnect,
|
||||
} from "./ssh-dns.js";
|
||||
|
||||
describe("SSH DNS resolution", () => {
|
||||
it("retries transient EAI_AGAIN errors before returning an address", async () => {
|
||||
const lookup = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("try again"), { code: "EAI_AGAIN" }),
|
||||
)
|
||||
.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 });
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
resolveHostForSshConnect("alp", lookup, [10], wait),
|
||||
).resolves.toEqual({
|
||||
host: "10.0.0.5",
|
||||
resolvedAddress: "10.0.0.5",
|
||||
attempts: 2,
|
||||
});
|
||||
expect(wait).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it("does not retry permanent DNS failures", async () => {
|
||||
const error = Object.assign(new Error("not found"), { code: "ENOTFOUND" });
|
||||
const lookup = vi.fn().mockRejectedValue(error);
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
resolveHostForSshConnect("missing", lookup, [10], wait),
|
||||
).rejects.toBe(error);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips DNS lookup for literal IP addresses", async () => {
|
||||
const lookup = vi.fn();
|
||||
|
||||
await expect(
|
||||
resolveHostForSshConnect("192.0.2.1", lookup),
|
||||
).resolves.toEqual({
|
||||
host: "192.0.2.1",
|
||||
attempts: 0,
|
||||
});
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects retryable DNS errors by code or message", () => {
|
||||
expect(isRetriableDnsError({ code: "EAI_AGAIN" })).toBe(true);
|
||||
expect(isRetriableDnsError(new Error("getaddrinfo EAI_AGAIN alp"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isRetriableDnsError({ code: "ENOTFOUND" })).toBe(false);
|
||||
});
|
||||
|
||||
it("only pre-resolves hostnames", () => {
|
||||
expect(shouldResolveBeforeSshConnect("alp")).toBe(true);
|
||||
expect(shouldResolveBeforeSshConnect("127.0.0.1")).toBe(false);
|
||||
expect(shouldResolveBeforeSshConnect("[2001:db8::1]")).toBe(false);
|
||||
});
|
||||
|
||||
it("updates SSH connect config hosts in place", async () => {
|
||||
const lookup = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ address: "10.0.0.6", family: 4 });
|
||||
const config = { host: "alp", port: 22 };
|
||||
|
||||
await expect(resolveSshConnectConfigHost(config, lookup)).resolves.toEqual({
|
||||
host: "10.0.0.6",
|
||||
port: 22,
|
||||
originalHost: "alp",
|
||||
resolvedHost: "10.0.0.6",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import dns from "dns/promises";
|
||||
import net from "net";
|
||||
|
||||
export const SSH_DNS_RETRY_DELAYS_MS = [250, 750, 1500];
|
||||
|
||||
type Lookup = typeof dns.lookup;
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
type SshConnectConfigHost = {
|
||||
host?: unknown;
|
||||
};
|
||||
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function isRetriableDnsError(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; message?: unknown };
|
||||
return (
|
||||
err.code === "EAI_AGAIN" ||
|
||||
(typeof err.message === "string" && err.message.includes("EAI_AGAIN"))
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldResolveBeforeSshConnect(host: string): boolean {
|
||||
const normalized = host.replace(/^\[|\]$/g, "").trim();
|
||||
if (!normalized) return false;
|
||||
return net.isIP(normalized) === 0;
|
||||
}
|
||||
|
||||
export async function resolveHostForSshConnect(
|
||||
host: string,
|
||||
lookup: Lookup = dns.lookup,
|
||||
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
|
||||
wait: Sleep = sleep,
|
||||
): Promise<{ host: string; resolvedAddress?: string; attempts: number }> {
|
||||
const normalized = host.replace(/^\[|\]$/g, "").trim();
|
||||
if (!shouldResolveBeforeSshConnect(normalized)) {
|
||||
return { host: normalized || host, attempts: 0 };
|
||||
}
|
||||
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const result = await lookup(normalized);
|
||||
return {
|
||||
host: result.address,
|
||||
resolvedAddress: result.address,
|
||||
attempts: attempt + 1,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isRetriableDnsError(error) || attempt >= retryDelaysMs.length) {
|
||||
throw error;
|
||||
}
|
||||
await wait(retryDelaysMs[attempt]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSshConnectConfigHost<
|
||||
T extends SshConnectConfigHost,
|
||||
>(
|
||||
config: T,
|
||||
lookup: Lookup = dns.lookup,
|
||||
retryDelaysMs = SSH_DNS_RETRY_DELAYS_MS,
|
||||
wait: Sleep = sleep,
|
||||
): Promise<
|
||||
T & { host?: unknown; resolvedHost?: string; originalHost?: string }
|
||||
> {
|
||||
if (typeof config.host !== "string") return config;
|
||||
|
||||
const originalHost = config.host;
|
||||
const resolution = await resolveHostForSshConnect(
|
||||
originalHost,
|
||||
lookup,
|
||||
retryDelaysMs,
|
||||
wait,
|
||||
);
|
||||
if (!resolution.resolvedAddress) return config;
|
||||
|
||||
config.host = resolution.host;
|
||||
return Object.assign(config, {
|
||||
originalHost,
|
||||
resolvedHost: resolution.resolvedAddress,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,19 @@ import ssh2Pkg, {
|
||||
} from "ssh2";
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
|
||||
type PortKnockingOptions = {
|
||||
tcpTimeoutMs?: number;
|
||||
udpTimeoutMs?: number;
|
||||
createTcpSocket?: () => net.Socket;
|
||||
createUdpSocket?: () => dgram.Socket;
|
||||
wait?: Sleep;
|
||||
};
|
||||
|
||||
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
@@ -97,34 +110,59 @@ export async function applyAgentAuth(
|
||||
export async function performPortKnocking(
|
||||
host: string,
|
||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||
options: PortKnockingOptions = {},
|
||||
): Promise<void> {
|
||||
const createTcpSocket = options.createTcpSocket ?? (() => new net.Socket());
|
||||
const createUdpSocket =
|
||||
options.createUdpSocket ?? (() => dgram.createSocket("udp4"));
|
||||
const wait = options.wait ?? sleep;
|
||||
const tcpTimeoutMs = options.tcpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
|
||||
const udpTimeoutMs = options.udpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
|
||||
|
||||
for (const knock of sequence) {
|
||||
const protocol = knock.protocol || "tcp";
|
||||
const delay = knock.delay ?? 100;
|
||||
const port = Number(knock.port);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
||||
|
||||
const protocol = (knock.protocol || "tcp").toLowerCase();
|
||||
const delay = Number(knock.delay ?? 100);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (protocol === "udp") {
|
||||
const client = dgram.createSocket("udp4");
|
||||
client.send(Buffer.alloc(0), knock.port, host, () => {
|
||||
const client = createUdpSocket();
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => finish(), udpTimeoutMs);
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
client.close();
|
||||
resolve();
|
||||
};
|
||||
client.once("error", finish);
|
||||
client.send(Buffer.alloc(0), port, host, () => {
|
||||
finish();
|
||||
});
|
||||
} else {
|
||||
const socket = new net.Socket();
|
||||
socket.once("connect", () => {
|
||||
const socket = createTcpSocket();
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => finish(), tcpTimeoutMs);
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
socket.removeAllListeners("connect");
|
||||
socket.removeAllListeners("error");
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.connect(knock.port, host);
|
||||
};
|
||||
socket.once("connect", finish);
|
||||
socket.once("error", finish);
|
||||
socket.connect(port, host);
|
||||
}
|
||||
});
|
||||
|
||||
if (delay > 0) {
|
||||
await new Promise<void>((r) => setTimeout(r, delay));
|
||||
await wait(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { performPortKnocking } from "./terminal-auth-helpers.js";
|
||||
|
||||
class FakeTcpSocket extends EventEmitter {
|
||||
readonly connect = vi.fn();
|
||||
readonly destroy = vi.fn();
|
||||
}
|
||||
|
||||
describe("performPortKnocking", () => {
|
||||
it("continues through TCP knock errors", async () => {
|
||||
const first = new FakeTcpSocket();
|
||||
const second = new FakeTcpSocket();
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const knocking = performPortKnocking(
|
||||
"192.0.2.10",
|
||||
[
|
||||
{ port: 1111, protocol: "tcp", delay: 10 },
|
||||
{ port: 2222, protocol: "tcp", delay: 0 },
|
||||
],
|
||||
{
|
||||
createTcpSocket: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce(second),
|
||||
wait,
|
||||
},
|
||||
);
|
||||
|
||||
first.emit("error", new Error("closed"));
|
||||
await Promise.resolve();
|
||||
second.emit("connect");
|
||||
await knocking;
|
||||
|
||||
expect(first.connect).toHaveBeenCalledWith(1111, "192.0.2.10");
|
||||
expect(second.connect).toHaveBeenCalledWith(2222, "192.0.2.10");
|
||||
expect(first.destroy).toHaveBeenCalled();
|
||||
expect(second.destroy).toHaveBeenCalled();
|
||||
expect(wait).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it("times out TCP knocks that are silently dropped", async () => {
|
||||
const socket = new FakeTcpSocket();
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await performPortKnocking("192.0.2.10", [{ port: 1111, delay: 0 }], {
|
||||
createTcpSocket: () => socket as never,
|
||||
tcpTimeoutMs: 1,
|
||||
wait,
|
||||
});
|
||||
|
||||
expect(socket.connect).toHaveBeenCalledWith(1111, "192.0.2.10");
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Stub all external imports before loading the module under test
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]);
|
||||
const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning });
|
||||
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
|
||||
|
||||
vi.mock("../database/db/index.js", () => ({
|
||||
@@ -29,21 +30,25 @@ vi.mock("../utils/logger.js", () => ({
|
||||
// Mock individual fs.promises methods via a stub object
|
||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
|
||||
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("fs", () => ({
|
||||
default: {
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
mkdir: mockMkdir,
|
||||
writeFile: mockWriteFile,
|
||||
appendFile: mockAppendFile,
|
||||
readFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
unlink: mockUnlink,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,7 +60,8 @@ describe("TerminalSessionManager - session logging", () => {
|
||||
// Re-apply resolved values after clearAllMocks
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockInsertValues.mockResolvedValue(undefined);
|
||||
mockReturning.mockResolvedValue([{ id: 1 }]);
|
||||
mockInsertValues.mockReturnValue({ returning: mockReturning });
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Client, type ClientChannel } from "ssh2";
|
||||
import { WebSocket } from "ws";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessionRecordings } from "../database/db/schema.js";
|
||||
@@ -36,6 +37,12 @@ export interface TerminalSession {
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
recordingPath: string | null;
|
||||
recordingHeader: string | null;
|
||||
recordingBytes: number;
|
||||
recordingId: number | null;
|
||||
recordingWriteChain: Promise<void>;
|
||||
recordingPersistChain: Promise<void>;
|
||||
tmuxSessionName: string | null;
|
||||
sessionLoggingEnabled: boolean;
|
||||
sessionStartedAt: number;
|
||||
@@ -117,6 +124,19 @@ class TerminalSessionManager {
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
let recordingPath: string | null = null;
|
||||
let recordingHeader: string | null = null;
|
||||
if (sessionLoggingEnabled) {
|
||||
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
|
||||
recordingPath = path.join(userLogDir, `${id}.cast`);
|
||||
recordingHeader = `${JSON.stringify({
|
||||
version: 2,
|
||||
width: cols,
|
||||
height: rows,
|
||||
timestamp: Math.floor(now / 1000),
|
||||
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
|
||||
})}\n`;
|
||||
}
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
userId,
|
||||
@@ -135,6 +155,12 @@ class TerminalSessionManager {
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
recordingPath,
|
||||
recordingHeader,
|
||||
recordingBytes: 0,
|
||||
recordingId: null,
|
||||
recordingWriteChain: Promise.resolve(),
|
||||
recordingPersistChain: Promise.resolve(),
|
||||
tmuxSessionName: null,
|
||||
sessionLoggingEnabled,
|
||||
sessionStartedAt: now,
|
||||
@@ -334,6 +360,9 @@ class TerminalSessionManager {
|
||||
}
|
||||
|
||||
this.maybePersistLog(session, true);
|
||||
if (session.recordingPath && session.recordingBytes === 0) {
|
||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||
}
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
@@ -376,31 +405,14 @@ class TerminalSessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
private stripAnsi(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// ESC sequences: CSI, OSC, DCS, PM, APC, SOS
|
||||
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, "")
|
||||
// Single-char ESC sequences (e.g. ESC M, ESC =)
|
||||
.replace(/\x1b[^[\]PX^_]/g, "")
|
||||
// Other C0/C1 control chars except newline, carriage return, tab
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
||||
// Collapse carriage returns used for line overwrites
|
||||
.replace(/[^\n]*\r(?!\n)/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
private maybePersistLog(session: TerminalSession, force = false): void {
|
||||
if (!session.sessionLoggingEnabled) return;
|
||||
if (session.outputBufferBytes === 0) return;
|
||||
// Only save if new output arrived since last persist (unless forced)
|
||||
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
|
||||
return;
|
||||
const snapshot = session.outputBuffer.join("");
|
||||
session.lastPersistedBytes = session.outputBufferBytes;
|
||||
this.persistSessionLog(session, snapshot).catch((err) => {
|
||||
if (session.recordingBytes === 0) return;
|
||||
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
||||
session.lastPersistedBytes = session.recordingBytes;
|
||||
session.recordingPersistChain = session.recordingPersistChain
|
||||
.then(() => this.persistSessionLog(session))
|
||||
.catch((err) => {
|
||||
sshLogger.warn("Failed to persist session log", {
|
||||
operation: "session_log_persist_error",
|
||||
sessionId: session.id,
|
||||
@@ -409,32 +421,35 @@ class TerminalSessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async persistSessionLog(
|
||||
session: TerminalSession,
|
||||
logContent: string,
|
||||
): Promise<void> {
|
||||
const cleaned = this.stripAnsi(logContent);
|
||||
if (!cleaned.trim()) return;
|
||||
|
||||
const userLogDir = path.join(SESSION_LOGS_DIR, session.userId);
|
||||
await fs.promises.mkdir(userLogDir, { recursive: true });
|
||||
|
||||
const logFile = path.join(userLogDir, `${session.id}.log`);
|
||||
await fs.promises.writeFile(logFile, cleaned, "utf-8");
|
||||
|
||||
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
||||
if (!session.recordingPath) return;
|
||||
await session.recordingWriteChain;
|
||||
const endedAt = Date.now();
|
||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.insert(sessionRecordings).values({
|
||||
if (session.recordingId == null) {
|
||||
const rows = await db
|
||||
.insert(sessionRecordings)
|
||||
.values({
|
||||
hostId: session.hostId,
|
||||
userId: session.userId,
|
||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
recordingPath: logFile,
|
||||
});
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
})
|
||||
.returning({ id: sessionRecordings.id });
|
||||
session.recordingId = rows[0]?.id ?? null;
|
||||
} else {
|
||||
await db
|
||||
.update(sessionRecordings)
|
||||
.set({ endedAt: new Date(endedAt).toISOString(), duration })
|
||||
.where(eq(sessionRecordings.id, session.recordingId));
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to insert session recording row", {
|
||||
operation: "session_recording_insert_error",
|
||||
@@ -449,7 +464,7 @@ class TerminalSessionManager {
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
duration,
|
||||
bytes: cleaned.length,
|
||||
bytes: session.recordingBytes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,6 +492,47 @@ class TerminalSessionManager {
|
||||
const removed = session.outputBuffer.shift();
|
||||
if (removed) session.outputBufferBytes -= removed.length;
|
||||
}
|
||||
|
||||
this.recordSessionEvent(session, "o", data);
|
||||
}
|
||||
|
||||
bufferInput(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "i", data);
|
||||
}
|
||||
|
||||
bufferResize(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
|
||||
}
|
||||
|
||||
private recordSessionEvent(
|
||||
session: TerminalSession,
|
||||
type: "i" | "o" | "r",
|
||||
data: string,
|
||||
): void {
|
||||
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
|
||||
return;
|
||||
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
|
||||
const line = `${JSON.stringify([elapsed, type, data])}\n`;
|
||||
const firstEvent = session.recordingBytes === 0;
|
||||
session.recordingBytes += Buffer.byteLength(line);
|
||||
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
|
||||
if (firstEvent) {
|
||||
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
session.recordingPath!,
|
||||
`${session.recordingHeader}${line}`,
|
||||
"utf8",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await fs.promises.appendFile(session.recordingPath!, line, "utf8");
|
||||
});
|
||||
}
|
||||
|
||||
flushBuffer(session: TerminalSession): string | null {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
import { triggerLoginAlert } from "../utils/alert-trigger.js";
|
||||
import { isRetriableDnsError, resolveHostForSshConnect } from "./ssh-dns.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -538,6 +539,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
case "input": {
|
||||
const inputData = data as string;
|
||||
if (currentSessionId) {
|
||||
sessionManager.bufferInput(currentSessionId, inputData);
|
||||
}
|
||||
const inputStream =
|
||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||
if (inputStream) {
|
||||
@@ -1142,6 +1146,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
|
||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||
enableSessionLogging?: boolean;
|
||||
})
|
||||
@@ -1185,6 +1190,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
|
||||
}
|
||||
|
||||
if (
|
||||
(!hostConfig.portKnockSequence ||
|
||||
hostConfig.portKnockSequence.length === 0) &&
|
||||
resolvedHostData.portKnockSequence &&
|
||||
resolvedHostData.portKnockSequence.length > 0
|
||||
) {
|
||||
hostConfig.portKnockSequence = resolvedHostData.portKnockSequence;
|
||||
sendLog(
|
||||
"port_knock",
|
||||
"info",
|
||||
`Loaded ${resolvedHostData.portKnockSequence.length} port knock(s) from server-side host data`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
|
||||
@@ -1267,6 +1286,39 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
sendLog("dns", "info", `Starting address resolution of ${ip}`);
|
||||
let connectHost = ip;
|
||||
try {
|
||||
const resolution = await resolveHostForSshConnect(ip);
|
||||
connectHost = resolution.host;
|
||||
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
|
||||
sendLog(
|
||||
"dns",
|
||||
"success",
|
||||
`Resolved ${ip} to ${resolution.resolvedAddress}`,
|
||||
{ attempts: resolution.attempts },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
sshLogger.error("SSH hostname resolution failed", error, {
|
||||
operation: "terminal_dns_resolve",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
transient: isRetriableDnsError(error),
|
||||
});
|
||||
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: isRetriableDnsError(error)
|
||||
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
|
||||
: "SSH error: Could not resolve hostname from the Termix server container.",
|
||||
}),
|
||||
);
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||
|
||||
sshConn.on("ready", () => {
|
||||
@@ -2305,7 +2357,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: ip,
|
||||
host: connectHost,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
||||
@@ -2864,6 +2916,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (session) {
|
||||
session.cols = data.cols;
|
||||
session.rows = data.rows;
|
||||
sessionManager.bufferResize(session.id, data.cols, data.rows);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tmuxCommand, withTmuxPath } from "./tmux-helper.js";
|
||||
import { detectTmux, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
|
||||
|
||||
describe("tmux command path handling", () => {
|
||||
it("adds common non-login shell tmux paths", () => {
|
||||
const command = withTmuxPath("command -v tmux");
|
||||
|
||||
expect(command).toMatch(/^\/bin\/sh -c '/);
|
||||
expect(command).toContain("/opt/homebrew/bin");
|
||||
expect(command).toContain("/usr/local/bin");
|
||||
expect(command).toContain("/opt/bin");
|
||||
expect(command).toContain("/usr/pkg/bin");
|
||||
expect(command).toContain(":$PATH; command -v tmux");
|
||||
expect(command).toContain(":$PATH; export PATH; command -v tmux");
|
||||
});
|
||||
|
||||
it("wraps tmux invocations with the same path", () => {
|
||||
expect(tmuxCommand("list-sessions")).toMatch(
|
||||
/^PATH=.*:\$PATH; tmux list-sessions$/,
|
||||
/^\/bin\/sh -c 'PATH=.*:\$PATH; export PATH; tmux list-sessions'$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects suffixed tmux versions without parsing the version number", async () => {
|
||||
const commands: string[] = [];
|
||||
const conn = {
|
||||
exec(command: string, callback: (error: null, stream: never) => void) {
|
||||
commands.push(command);
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream as never);
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (commands.length === 1) {
|
||||
stream.emit("data", Buffer.from("tmux 3.7b\n"));
|
||||
stream.emit("close", 0);
|
||||
return;
|
||||
}
|
||||
stream.emit("close", 1);
|
||||
});
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
await expect(detectTmux(conn)).resolves.toEqual({
|
||||
available: true,
|
||||
sessions: [],
|
||||
});
|
||||
expect(commands[0]).toContain("tmux -V");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,8 @@ const TMUX_PATH_DIRS = [
|
||||
];
|
||||
|
||||
export function withTmuxPath(command: string): string {
|
||||
return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`;
|
||||
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; export PATH; ${command}`;
|
||||
return `/bin/sh -c ${shellEscape(script)}`;
|
||||
}
|
||||
|
||||
export function tmuxCommand(args: string): string {
|
||||
@@ -69,7 +70,7 @@ export function execCommand(conn: Client, command: string): Promise<string> {
|
||||
*/
|
||||
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||
try {
|
||||
await execCommand(conn, withTmuxPath("command -v tmux"));
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
} catch {
|
||||
return { available: false, sessions: [] };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { withConnection } from "./ssh-connection-pool.js";
|
||||
import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
import { execCommand, tmuxCommand } from "./tmux-helper.js";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
@@ -95,6 +96,8 @@ async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
|
||||
}
|
||||
} else if (host.authType === "none") {
|
||||
// no credentials needed
|
||||
} else if (host.authType === "vault") {
|
||||
// cert auth setup happens in connectToHost (needs client instance)
|
||||
} else if (host.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
base as Record<string, unknown>,
|
||||
@@ -118,6 +121,12 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("./vault-ssh-connect.js");
|
||||
await setupVaultSshSignerAuth(config, client, host);
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
@@ -201,8 +210,17 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
} else if (config.sock) {
|
||||
client.connect(config);
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -228,7 +246,7 @@ async function withHostConnection<T>(
|
||||
|
||||
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||
try {
|
||||
await execCommand(conn, withTmuxPath("command -v tmux"));
|
||||
await execCommand(conn, tmuxCommand("-V"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
handleC2SRelayTest,
|
||||
type C2SOpenMessage,
|
||||
} from "./tunnel-c2s-relay.js";
|
||||
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||
import { handleSocks5Connect } from "./tunnel-socks5-relay.js";
|
||||
|
||||
const app = express();
|
||||
@@ -1507,6 +1508,27 @@ async function connectSSHTunnel(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await resolveSshConnectConfigHost(connOptions);
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Tunnel source hostname resolution failed", error, {
|
||||
operation: "tunnel_dns_resolve",
|
||||
tunnelName,
|
||||
sourceHost: `${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
|
||||
retryAttempt,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to resolve tunnel source hostname",
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connOptions);
|
||||
}
|
||||
|
||||
@@ -1693,6 +1715,10 @@ async function killRemoteTunnelByMarker(
|
||||
}
|
||||
}
|
||||
|
||||
if (!connOptions.sock) {
|
||||
await resolveSshConnectConfigHost(connOptions);
|
||||
}
|
||||
|
||||
return new Promise<Client>((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn.on("ready", () => resolve(conn));
|
||||
@@ -1937,6 +1963,7 @@ app.post(
|
||||
}
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
tunnelConfig.requestingUserId = userId;
|
||||
|
||||
try {
|
||||
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||
@@ -1967,10 +1994,6 @@ app.post(
|
||||
});
|
||||
return res.status(403).json({ error: "Access denied to this host" });
|
||||
}
|
||||
|
||||
if (accessInfo.isShared && !accessInfo.isOwner) {
|
||||
tunnelConfig.requestingUserId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTunnelOperations.has(tunnelName)) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
|
||||
type VaultAuthHost = {
|
||||
id: number;
|
||||
username: string;
|
||||
userId?: string | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
};
|
||||
|
||||
export async function setupVaultSshSignerAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
host: VaultAuthHost,
|
||||
): Promise<void> {
|
||||
if (!host.userId) {
|
||||
throw new Error("Vault SSH signer authentication requires a user session");
|
||||
}
|
||||
|
||||
const vaultProfileId = host.vaultProfile?.id;
|
||||
if (!vaultProfileId) {
|
||||
throw new Error("Host has no Vault signer profile configured");
|
||||
}
|
||||
|
||||
const { getVaultCert } = await import("./vault-signer-auth.js");
|
||||
const cert = await getVaultCert(host.userId, vaultProfileId);
|
||||
if (!cert) {
|
||||
throw new Error(
|
||||
"Vault SSH signer authentication required. Please open a Terminal connection first.",
|
||||
);
|
||||
}
|
||||
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config,
|
||||
client,
|
||||
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
|
||||
host.username,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sqlite: null as {
|
||||
exec: (sql: string) => void;
|
||||
prepare: (sql: string) => {
|
||||
all: () => Array<Record<string, unknown>>;
|
||||
run: (...values: unknown[]) => unknown;
|
||||
};
|
||||
} | null,
|
||||
logoutUser: vi.fn(),
|
||||
saveDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../database/db/index.js", async () => {
|
||||
const { default: Database } = await import("better-sqlite3");
|
||||
const { drizzle } = await import("drizzle-orm/better-sqlite3");
|
||||
const sqlite = new Database(":memory:");
|
||||
mocks.sqlite = sqlite;
|
||||
return {
|
||||
db: drizzle(sqlite),
|
||||
saveMemoryDatabaseToFile: mocks.saveDatabase,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./user-crypto.js", () => ({
|
||||
UserCrypto: {
|
||||
getInstance: () => ({
|
||||
setSessionExpiredCallback: vi.fn(),
|
||||
logoutUser: mocks.logoutUser,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./system-crypto.js", () => ({
|
||||
SystemCrypto: { getInstance: () => ({}) },
|
||||
}));
|
||||
|
||||
vi.mock("./data-crypto.js", () => ({
|
||||
DataCrypto: { getInstance: () => ({}) },
|
||||
}));
|
||||
|
||||
vi.mock("./logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { AuthManager } = await import("./auth-manager.js");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
type SessionInput = {
|
||||
id: string;
|
||||
userId: string;
|
||||
sub: string;
|
||||
sid: string | null;
|
||||
providerId: number | null;
|
||||
};
|
||||
|
||||
function insertSession({ id, userId, sub, sid, providerId }: SessionInput) {
|
||||
mocks
|
||||
.sqlite!.prepare(
|
||||
`INSERT INTO sessions (
|
||||
id, user_id, jwt_token, device_type, device_info,
|
||||
oidc_sub, oidc_sid, sso_provider_id,
|
||||
created_at, expires_at, last_active_at
|
||||
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
userId,
|
||||
`token-${id}`,
|
||||
sub,
|
||||
sid,
|
||||
providerId,
|
||||
"2026-07-10T00:00:00.000Z",
|
||||
"2026-07-11T00:00:00.000Z",
|
||||
"2026-07-10T00:00:00.000Z",
|
||||
);
|
||||
}
|
||||
|
||||
function sessionIds(): string[] {
|
||||
return mocks
|
||||
.sqlite!.prepare("SELECT id FROM sessions ORDER BY id")
|
||||
.all()
|
||||
.map((row) => String(row.id));
|
||||
}
|
||||
|
||||
describe("AuthManager.revokeSessionsByOidc", () => {
|
||||
beforeAll(() => {
|
||||
mocks.sqlite!.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
oidc_sub TEXT,
|
||||
oidc_sid TEXT,
|
||||
sso_provider_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_active_at TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.sqlite!.exec("DELETE FROM sessions");
|
||||
mocks.logoutUser.mockReset();
|
||||
mocks.saveDatabase.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("revokes only the matching provider session when sid is present", async () => {
|
||||
insertSession({
|
||||
id: "matching",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
providerId: 7,
|
||||
});
|
||||
insertSession({
|
||||
id: "other-provider",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
providerId: 8,
|
||||
});
|
||||
insertSession({
|
||||
id: "other-session",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-2",
|
||||
providerId: 7,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(sessionIds()).toEqual(["other-provider", "other-session"]);
|
||||
expect(mocks.logoutUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes all provider sessions for a subject when sid is absent", async () => {
|
||||
insertSession({
|
||||
id: "first",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
providerId: 7,
|
||||
});
|
||||
insertSession({
|
||||
id: "second",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-2",
|
||||
providerId: 7,
|
||||
});
|
||||
insertSession({
|
||||
id: "other-subject",
|
||||
userId: "user-2",
|
||||
sub: "subject-2",
|
||||
sid: null,
|
||||
providerId: 7,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: 7,
|
||||
sub: "subject-1",
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
|
||||
expect(sessionIds()).toEqual(["other-subject"]);
|
||||
expect(mocks.logoutUser).toHaveBeenCalledOnce();
|
||||
expect(mocks.logoutUser).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("does not persist when no session matches", async () => {
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: 7,
|
||||
sid: "missing",
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(mocks.saveDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates persistence failures so the provider can retry", async () => {
|
||||
insertSession({
|
||||
id: "matching",
|
||||
userId: "user-1",
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
providerId: 7,
|
||||
});
|
||||
mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
await expect(
|
||||
authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: 7,
|
||||
sid: "session-1",
|
||||
}),
|
||||
).rejects.toThrow("disk full");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { Request, Response, NextFunction } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
|
||||
@@ -42,6 +42,7 @@ interface WrappedDataKey {
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
@@ -345,6 +346,9 @@ class AuthManager {
|
||||
rememberMe?: boolean;
|
||||
deviceType?: DeviceType;
|
||||
deviceInfo?: string;
|
||||
oidcSub?: string | null;
|
||||
oidcSid?: string | null;
|
||||
ssoProviderId?: number | null;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
@@ -391,6 +395,9 @@ class AuthManager {
|
||||
jwtToken: token,
|
||||
deviceType: options.deviceType,
|
||||
deviceInfo: options.deviceInfo,
|
||||
oidcSub: options.oidcSub ?? null,
|
||||
oidcSid: options.oidcSid ?? null,
|
||||
ssoProviderId: options.ssoProviderId ?? null,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
lastActiveAt: createdAt,
|
||||
@@ -651,6 +658,62 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
async revokeSessionsByOidc(params: {
|
||||
ssoProviderId?: number | null;
|
||||
sub?: string | null;
|
||||
sid?: string | null;
|
||||
}): Promise<number> {
|
||||
const { ssoProviderId, sub, sid } = params;
|
||||
if (!sub && !sid) return 0;
|
||||
|
||||
try {
|
||||
const conditions = [
|
||||
sid ? eq(sessions.oidcSid, sid) : eq(sessions.oidcSub, sub!),
|
||||
];
|
||||
if (ssoProviderId != null)
|
||||
conditions.push(eq(sessions.ssoProviderId, ssoProviderId));
|
||||
|
||||
const matched = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
||||
|
||||
if (matched.length === 0) return 0;
|
||||
|
||||
const matchedIds = matched.map((s) => s.id);
|
||||
const affectedUsers = new Set(matched.map((s) => s.userId));
|
||||
|
||||
await db.delete(sessions).where(inArray(sessions.id, matchedIds));
|
||||
|
||||
authLogger.info("Sessions revoked via OIDC back-channel logout", {
|
||||
operation: "oidc_backchannel_logout",
|
||||
ssoProviderId,
|
||||
sessionCount: matchedIds.length,
|
||||
});
|
||||
|
||||
for (const userId of affectedUsers) {
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
if (remaining.length === 0) {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return matchedIds.length;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke sessions via OIDC", error, {
|
||||
operation: "oidc_backchannel_logout_failed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupExpiredSessions(): Promise<number> {
|
||||
try {
|
||||
const expiredSessions = await db
|
||||
@@ -819,6 +882,7 @@ class AuthManager {
|
||||
});
|
||||
|
||||
req.userId = matchedKey.userId;
|
||||
req.apiKeyId = matchedKey.id;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("API key authentication failed", error, {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDesktopOidcCallbackUrl,
|
||||
isOidcTokenCallback,
|
||||
} from "./oidc-desktop-callback";
|
||||
|
||||
describe("getDesktopOidcCallbackUrl", () => {
|
||||
it("uses localhost so browsers do not upgrade the loopback callback", () => {
|
||||
expect(getDesktopOidcCallbackUrl("17850")).toBe(
|
||||
"http://localhost:17850/oidc-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "0", "65536", "17850/path", ["17850"]])(
|
||||
"rejects invalid callback port %j",
|
||||
(port) => {
|
||||
expect(getDesktopOidcCallbackUrl(port)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("isOidcTokenCallback", () => {
|
||||
it.each([
|
||||
"http://localhost:17850/oidc-callback",
|
||||
"http://127.0.0.1:17850/oidc-callback",
|
||||
"termix-mobile://oidc-callback",
|
||||
])("recognizes app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://localhost:17850/oidc-callback",
|
||||
"http://example.com:17850/oidc-callback",
|
||||
"http://localhost:17850/other",
|
||||
])("rejects non-app callback %s", (url) => {
|
||||
expect(isOidcTokenCallback(url)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const CALLBACK_PATH = "/oidc-callback";
|
||||
|
||||
export function getDesktopOidcCallbackUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
|
||||
return `http://localhost:${port}${CALLBACK_PATH}`;
|
||||
}
|
||||
|
||||
export function isOidcTokenCallback(value: string): boolean {
|
||||
if (value.startsWith("termix-mobile:")) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "http:" &&
|
||||
(url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
|
||||
url.pathname === CALLBACK_PATH
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getRequestBasePath,
|
||||
getRequestBaseUrl,
|
||||
getRequestBaseUrlWithForceHTTPS,
|
||||
getRequestOrigin,
|
||||
normalizeBasePath,
|
||||
} from "./request-origin.js";
|
||||
|
||||
@@ -106,3 +107,52 @@ describe("getRequestBasePath", () => {
|
||||
).toBe("https://example.com/termix");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestOrigin", () => {
|
||||
it("ignores non-numeric forwarded ports", () => {
|
||||
expect(
|
||||
getRequestOrigin(
|
||||
request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "termix.test.de",
|
||||
"x-forwarded-port": "{server_port}",
|
||||
}),
|
||||
),
|
||||
).toBe("https://termix.test.de");
|
||||
});
|
||||
|
||||
it("drops invalid ports embedded in forwarded hosts", () => {
|
||||
expect(
|
||||
getRequestOrigin(
|
||||
request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "termix.test.de:{server_port}",
|
||||
}),
|
||||
),
|
||||
).toBe("https://termix.test.de");
|
||||
});
|
||||
|
||||
it("keeps valid non-default forwarded ports", () => {
|
||||
expect(
|
||||
getRequestOrigin(
|
||||
request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "termix.test.de",
|
||||
"x-forwarded-port": "8443",
|
||||
}),
|
||||
),
|
||||
).toBe("https://termix.test.de:8443");
|
||||
});
|
||||
|
||||
it("omits default forwarded ports", () => {
|
||||
expect(
|
||||
getRequestOrigin(
|
||||
request({
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "termix.test.de",
|
||||
"x-forwarded-port": "443",
|
||||
}),
|
||||
),
|
||||
).toBe("https://termix.test.de");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,44 @@ function firstHeaderValue(value: string | string[] | undefined): string {
|
||||
return raw.split(",")[0].trim();
|
||||
}
|
||||
|
||||
function normalizePort(value: string | string[] | undefined): string {
|
||||
const raw = firstHeaderValue(value);
|
||||
if (!/^\d+$/.test(raw)) return "";
|
||||
|
||||
const port = Number(raw);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return "";
|
||||
|
||||
return String(port);
|
||||
}
|
||||
|
||||
function splitHostHeader(value: string | string[] | undefined): {
|
||||
host: string;
|
||||
port: string;
|
||||
} {
|
||||
const raw = firstHeaderValue(value) || "localhost";
|
||||
|
||||
if (raw.startsWith("[")) {
|
||||
const match = raw.match(/^(\[[^\]]+\])(?::(.+))?$/);
|
||||
return {
|
||||
host: match?.[1] || raw,
|
||||
port: normalizePort(match?.[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const parts = raw.split(":");
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
host: parts[0] || "localhost",
|
||||
port: normalizePort(parts[1]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
host: raw,
|
||||
port: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBasePath(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
let basePath = value.split(",")[0].trim();
|
||||
@@ -45,38 +83,23 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
: "http";
|
||||
}
|
||||
|
||||
const portHeader = req.headers["x-forwarded-port"];
|
||||
let port: string | undefined =
|
||||
typeof portHeader === "string"
|
||||
? portHeader.split(",")[0].trim()
|
||||
: undefined;
|
||||
let port = normalizePort(req.headers["x-forwarded-port"]);
|
||||
const { host, port: hostPort } = splitHostHeader(
|
||||
req.headers["x-forwarded-host"] || req.headers.host,
|
||||
);
|
||||
port ||= hostPort;
|
||||
|
||||
const hostHeaderRaw =
|
||||
req.headers["x-forwarded-host"] || req.headers.host || "localhost";
|
||||
const hostHeader =
|
||||
typeof hostHeaderRaw === "string"
|
||||
? hostHeaderRaw.split(",")[0].trim()
|
||||
: String(hostHeaderRaw);
|
||||
|
||||
if (!port && hostHeader.includes(":")) {
|
||||
const parts = hostHeader.split(":");
|
||||
if (parts.length === 2 && !parts[0].includes("[")) {
|
||||
port = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
const hostWithoutPort = hostHeader.split(":")[0];
|
||||
if (port) {
|
||||
const isDefaultPort =
|
||||
(protocol === "http" && port === "80") ||
|
||||
(protocol === "https" && port === "443");
|
||||
|
||||
return isDefaultPort
|
||||
? `${protocol}://${hostWithoutPort}`
|
||||
: `${protocol}://${hostWithoutPort}:${port}`;
|
||||
? `${protocol}://${host}`
|
||||
: `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
||||
return `${protocol}://${hostWithoutPort}`;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export function getRequestOriginWithForceHTTPS(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Toaster } from "@/components/sonner";
|
||||
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
||||
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom";
|
||||
import type { FontSizeId } from "@/types/ui-types";
|
||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -340,6 +341,8 @@ function RootApp() {
|
||||
return <App />;
|
||||
}
|
||||
|
||||
installElectronWheelZoomGuard();
|
||||
|
||||
prepareClientCacheVersion().finally(() => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
Vendored
+20
@@ -41,6 +41,26 @@ declare module "guacamole-common-js" {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class SessionRecording {
|
||||
constructor(source: Blob | Tunnel, refreshInterval?: number);
|
||||
onload: (() => void) | null;
|
||||
onerror: ((message: string) => void) | null;
|
||||
onprogress: ((duration: number, parsedSize: number) => void) | null;
|
||||
onplay: (() => void) | null;
|
||||
onpause: (() => void) | null;
|
||||
onseek:
|
||||
| ((position: number, current: number, total: number) => void)
|
||||
| null;
|
||||
getDisplay(): Display;
|
||||
getPosition(): number;
|
||||
getDuration(): number;
|
||||
isPlaying(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seek(position: number, callback?: () => void): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
|
||||
+5
-1
@@ -109,7 +109,8 @@ export interface Host {
|
||||
| "none"
|
||||
| "opkssh"
|
||||
| "tailscale"
|
||||
| "agent";
|
||||
| "agent"
|
||||
| "vault";
|
||||
useWarpgate?: boolean;
|
||||
password?: string;
|
||||
key?: string;
|
||||
@@ -123,6 +124,8 @@ export interface Host {
|
||||
autostartKeyPassword?: string;
|
||||
|
||||
credentialId?: number;
|
||||
vaultProfileId?: number | null;
|
||||
vaultProfile?: { id?: number | null };
|
||||
overrideCredentialUsername?: boolean;
|
||||
userId?: string;
|
||||
enableTerminal: boolean;
|
||||
@@ -952,6 +955,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
+116
-22
@@ -6,30 +6,101 @@ import { Separator } from "@/components/separator";
|
||||
import { Button } from "@/components/button";
|
||||
import { Sheet, SheetContent } from "@/components/sheet";
|
||||
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
||||
import { useState, useRef, useCallback, useEffect, createRef } from "react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
createRef,
|
||||
lazy,
|
||||
Suspense,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
||||
import { CommandPalette } from "@/shell/CommandPalette";
|
||||
import { AppRail } from "@/sidebar/AppRail";
|
||||
import type { RailView } from "@/sidebar/AppRail";
|
||||
import { HostsPanel } from "@/sidebar/HostsPanel";
|
||||
import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
|
||||
import { SerialPanel } from "@/sidebar/SerialPanel";
|
||||
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
|
||||
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
|
||||
import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
||||
import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
|
||||
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
||||
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
||||
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
||||
import { AlertsPanel } from "@/sidebar/AlertsPanel";
|
||||
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
|
||||
import { TermixIdPanel } from "@/sidebar/TermixIdPanel";
|
||||
import { SplitView } from "@/shell/SplitView";
|
||||
import { renderTabContent } from "@/shell/tabUtils";
|
||||
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager";
|
||||
import { TabBar } from "@/shell/TabBar";
|
||||
|
||||
// Shell surfaces that are not needed for first paint.
|
||||
const CommandPalette = lazy(() =>
|
||||
import("@/shell/CommandPalette").then((m) => ({
|
||||
default: m.CommandPalette,
|
||||
})),
|
||||
);
|
||||
const HostsPanel = lazy(() =>
|
||||
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
|
||||
);
|
||||
const QuickConnectPanel = lazy(() =>
|
||||
import("@/sidebar/QuickConnectPanel").then((m) => ({
|
||||
default: m.QuickConnectPanel,
|
||||
})),
|
||||
);
|
||||
const SerialPanel = lazy(() =>
|
||||
import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })),
|
||||
);
|
||||
const SplitScreenPanel = lazy(() =>
|
||||
import("@/sidebar/SplitScreenPanel").then((m) => ({
|
||||
default: m.SplitScreenPanel,
|
||||
})),
|
||||
);
|
||||
const AlertManager = lazy(() =>
|
||||
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
|
||||
default: m.AlertManager,
|
||||
})),
|
||||
);
|
||||
|
||||
// Secondary rail panels — load on first open, not with the shell critical path.
|
||||
const SshToolsPanel = lazy(() =>
|
||||
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
||||
);
|
||||
const SnippetsPanel = lazy(() =>
|
||||
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
|
||||
);
|
||||
const HistoryPanel = lazy(() =>
|
||||
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
|
||||
);
|
||||
const SessionLogsPanel = lazy(() =>
|
||||
import("@/sidebar/SessionLogsPanel").then((m) => ({
|
||||
default: m.SessionLogsPanel,
|
||||
})),
|
||||
);
|
||||
const UserProfilePanel = lazy(() =>
|
||||
import("@/sidebar/UserProfilePanel").then((m) => ({
|
||||
default: m.UserProfilePanel,
|
||||
})),
|
||||
);
|
||||
const AdminSettingsPanel = lazy(() =>
|
||||
import("@/sidebar/AdminSettingsPanel").then((m) => ({
|
||||
default: m.AdminSettingsPanel,
|
||||
})),
|
||||
);
|
||||
const AlertsPanel = lazy(() =>
|
||||
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
|
||||
);
|
||||
const CredentialsPanel = lazy(() =>
|
||||
import("@/sidebar/CredentialsPanel").then((m) => ({
|
||||
default: m.CredentialsPanel,
|
||||
})),
|
||||
);
|
||||
const TermixIdPanel = lazy(() =>
|
||||
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
|
||||
);
|
||||
const ConnectionsPanel = lazy(() =>
|
||||
import("@/sidebar/ConnectionsPanel").then((m) => ({
|
||||
default: m.ConnectionsPanel,
|
||||
})),
|
||||
);
|
||||
|
||||
function SidebarPanelFallback() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import type {
|
||||
Tab,
|
||||
TabType,
|
||||
@@ -59,10 +130,10 @@ import {
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
|
||||
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { changeAppLanguage } from "@/i18n/i18n";
|
||||
|
||||
function buildHostTree(
|
||||
hosts: SSHHostWithStatus[],
|
||||
@@ -618,8 +689,7 @@ export function AppShell({
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
if (prefs.language && prefs.language !== i18n.language) {
|
||||
localStorage.setItem("i18nextLng", prefs.language);
|
||||
void i18n.changeLanguage(prefs.language);
|
||||
void changeAppLanguage(prefs.language);
|
||||
}
|
||||
if (
|
||||
prefs.commandAutocomplete !== null &&
|
||||
@@ -738,8 +808,17 @@ export function AppShell({
|
||||
}, [loadHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("termix:hosts-changed", loadHosts);
|
||||
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
|
||||
const onHostsChanged = () => {
|
||||
void loadHosts();
|
||||
};
|
||||
window.addEventListener("termix:hosts-changed", onHostsChanged);
|
||||
window.addEventListener("ssh-hosts:changed", onHostsChanged);
|
||||
window.addEventListener("hosts:refresh", onHostsChanged);
|
||||
return () => {
|
||||
window.removeEventListener("termix:hosts-changed", onHostsChanged);
|
||||
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
|
||||
window.removeEventListener("hosts:refresh", onHostsChanged);
|
||||
};
|
||||
}, [loadHosts]);
|
||||
|
||||
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
||||
@@ -1437,6 +1516,7 @@ export function AppShell({
|
||||
|
||||
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
|
||||
const sidebarPanelContent = (
|
||||
<Suspense fallback={<SidebarPanelFallback />}>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
||||
@@ -1507,7 +1587,10 @@ export function AppShell({
|
||||
|
||||
{railView === "history" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} />
|
||||
<HistoryPanel
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1600,6 +1683,7 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
// Sidebar header — shared
|
||||
@@ -1713,6 +1797,10 @@ export function AppShell({
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
}}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
@@ -1808,6 +1896,8 @@ export function AppShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commandPaletteOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CommandPalette
|
||||
isOpen={commandPaletteOpen}
|
||||
setIsOpen={setCommandPaletteOpen}
|
||||
@@ -1835,8 +1925,12 @@ export function AppShell({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<TransferMonitor />
|
||||
<Suspense fallback={null}>
|
||||
<AlertManager userId={userId} loggedIn={!!username} />
|
||||
</Suspense>
|
||||
</ServerStatusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAlertFiring } from "./alerts-api";
|
||||
|
||||
describe("mapAlertFiring", () => {
|
||||
it("normalizes sqlite snake_case rows for the alerts UI", () => {
|
||||
expect(
|
||||
mapAlertFiring({
|
||||
id: 9,
|
||||
user_id: "u1",
|
||||
rule_id: 4,
|
||||
host_id: 12,
|
||||
host_name: "db-01",
|
||||
fired_at: "2026-06-30 12:00:00",
|
||||
resolved_at: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: 0,
|
||||
rule_name: "CPU",
|
||||
}),
|
||||
).toEqual({
|
||||
id: 9,
|
||||
userId: "u1",
|
||||
ruleId: 4,
|
||||
hostId: 12,
|
||||
hostName: "db-01",
|
||||
firedAt: "2026-06-30 12:00:00",
|
||||
resolvedAt: null,
|
||||
value: 91,
|
||||
message: "CPU threshold exceeded",
|
||||
severity: "critical",
|
||||
acknowledged: false,
|
||||
ruleName: "CPU",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses safe defaults for malformed rows", () => {
|
||||
const firing = mapAlertFiring({ acknowledged: 1, severity: "bad" });
|
||||
|
||||
expect(firing.id).toBe(0);
|
||||
expect(firing.hostName).toBe("");
|
||||
expect(firing.severity).toBe("warning");
|
||||
expect(firing.acknowledged).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,28 @@ export interface AlertFiring {
|
||||
ruleName?: string;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function nullableNumberValue(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function boolValue(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
function severityValue(value: unknown): AlertFiring["severity"] {
|
||||
return value === "info" || value === "critical" || value === "warning"
|
||||
? value
|
||||
: "warning";
|
||||
}
|
||||
|
||||
export async function getNotificationChannels(): Promise<
|
||||
NotificationChannel[]
|
||||
> {
|
||||
@@ -91,6 +113,23 @@ function mapRule(r: Record<string, unknown>): AlertRule {
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAlertFiring(r: Record<string, unknown>): AlertFiring {
|
||||
return {
|
||||
id: numberValue(r.id),
|
||||
userId: stringValue(r.userId ?? r.user_id),
|
||||
ruleId: numberValue(r.ruleId ?? r.rule_id),
|
||||
hostId: numberValue(r.hostId ?? r.host_id),
|
||||
hostName: stringValue(r.hostName ?? r.host_name),
|
||||
firedAt: stringValue(r.firedAt ?? r.fired_at, new Date(0).toISOString()),
|
||||
resolvedAt: stringValue(r.resolvedAt ?? r.resolved_at) || null,
|
||||
value: nullableNumberValue(r.value),
|
||||
message: stringValue(r.message),
|
||||
severity: severityValue(r.severity),
|
||||
acknowledged: boolValue(r.acknowledged),
|
||||
ruleName: stringValue(r.ruleName ?? r.rule_name) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAlertRules(): Promise<AlertRule[]> {
|
||||
const res = await rbacApi.get("/alert-rules");
|
||||
return (res.data as Record<string, unknown>[]).map(mapRule);
|
||||
@@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: {
|
||||
acknowledged?: boolean;
|
||||
}): Promise<AlertFiring[]> {
|
||||
const res = await rbacApi.get("/alert-firings", { params: opts });
|
||||
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
|
||||
const data = res.data as { firings?: unknown } | unknown[];
|
||||
const rows = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(data.firings)
|
||||
? data.firings
|
||||
: [];
|
||||
return rows.map((row) => mapAlertFiring(row as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function acknowledgeAlertFiring(id: number): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapAuditLog } from "./audit-log-api";
|
||||
|
||||
describe("mapAuditLog", () => {
|
||||
it("normalizes sqlite snake_case rows for the audit log UI", () => {
|
||||
expect(
|
||||
mapAuditLog({
|
||||
id: 3,
|
||||
user_id: "u1",
|
||||
username: "admin",
|
||||
action: "host_create",
|
||||
resource_type: "host",
|
||||
resource_id: "42",
|
||||
resource_name: "prod",
|
||||
details: "created",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "browser",
|
||||
success: 1,
|
||||
error_message: null,
|
||||
timestamp: "2026-06-30 12:00:00",
|
||||
}),
|
||||
).toEqual({
|
||||
id: 3,
|
||||
userId: "u1",
|
||||
username: "admin",
|
||||
action: "host_create",
|
||||
resourceType: "host",
|
||||
resourceId: "42",
|
||||
resourceName: "prod",
|
||||
details: "created",
|
||||
ipAddress: "127.0.0.1",
|
||||
userAgent: "browser",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
timestamp: "2026-06-30 12:00:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses render-safe defaults for malformed rows", () => {
|
||||
const log = mapAuditLog({ success: 0 });
|
||||
|
||||
expect(log.id).toBe(0);
|
||||
expect(log.action).toBe("unknown");
|
||||
expect(log.resourceType).toBe("unknown");
|
||||
expect(log.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,40 @@ export interface AuditLogResponse {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function nullableStringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function boolValue(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1";
|
||||
}
|
||||
|
||||
export function mapAuditLog(row: Record<string, unknown>): AuditLog {
|
||||
return {
|
||||
id: numberValue(row.id),
|
||||
userId: stringValue(row.userId ?? row.user_id),
|
||||
username: stringValue(row.username),
|
||||
action: stringValue(row.action, "unknown"),
|
||||
resourceType: stringValue(row.resourceType ?? row.resource_type, "unknown"),
|
||||
resourceId: nullableStringValue(row.resourceId ?? row.resource_id),
|
||||
resourceName: nullableStringValue(row.resourceName ?? row.resource_name),
|
||||
details: nullableStringValue(row.details),
|
||||
ipAddress: nullableStringValue(row.ipAddress ?? row.ip_address),
|
||||
userAgent: nullableStringValue(row.userAgent ?? row.user_agent),
|
||||
success: boolValue(row.success),
|
||||
errorMessage: nullableStringValue(row.errorMessage ?? row.error_message),
|
||||
timestamp: stringValue(row.timestamp, new Date(0).toISOString()),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAuditLogs(
|
||||
filters: AuditLogFilters = {},
|
||||
): Promise<AuditLogResponse> {
|
||||
@@ -50,7 +84,14 @@ export async function getAuditLogs(
|
||||
if (filters.endDate) params.set("endDate", filters.endDate);
|
||||
|
||||
const response = await authApi.get(`/audit-logs?${params.toString()}`);
|
||||
return response.data;
|
||||
const data = response.data as Partial<AuditLogResponse>;
|
||||
const rows = Array.isArray(data.logs) ? data.logs : [];
|
||||
return {
|
||||
logs: rows.map((row) => mapAuditLog(row as Record<string, unknown>)),
|
||||
total: numberValue(data.total),
|
||||
page: numberValue(data.page, filters.page ?? 1),
|
||||
totalPages: numberValue(data.totalPages, 1),
|
||||
};
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch audit logs");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
@@ -73,6 +74,7 @@ function isTransientStatusError(error: unknown): boolean {
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
@@ -101,6 +103,8 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -42,6 +43,8 @@ export interface ActiveSessionInfo {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||
|
||||
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
|
||||
const response = await authApi.get("/open-tabs");
|
||||
return response.data;
|
||||
@@ -69,8 +72,10 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
return activeSessionsCache.get(async () => {
|
||||
const response = await authApi.get("/open-tabs/active-sessions");
|
||||
return response.data;
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -11,6 +11,9 @@ export type SessionLogRecord = {
|
||||
hostName: string | null;
|
||||
hostIp: string | null;
|
||||
sizeBytes: number | null;
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
format: "text" | "asciicast" | "guacamole";
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
|
||||
@@ -22,6 +25,36 @@ export async function getSessionLogs(): Promise<SessionLogRecord[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionLogBlob(id: number): Promise<Blob> {
|
||||
try {
|
||||
const response = await authApi.get(`/session_logs/${id}/content`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session recording");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionRecordingRetention(): Promise<number> {
|
||||
try {
|
||||
const response = await authApi.get("/session_logs/retention");
|
||||
return response.data.retentionDays;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session recording retention");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSessionRecordingRetention(
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.put("/session_logs/retention", { retentionDays });
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update session recording retention");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionLogContent(id: number): Promise<string> {
|
||||
try {
|
||||
const response = await authApi.get(`/session_logs/${id}/content`, {
|
||||
|
||||
@@ -410,7 +410,7 @@ export async function uploadSSHFile(
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await fileManagerApi.postForm(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
|
||||
@@ -8,16 +8,38 @@ import {
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
|
||||
import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
} from "@/lib/hosts-request-cache";
|
||||
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
export type GetSSHHostsOptions = {
|
||||
/** When false, skip the status service call (host config only). Default true. */
|
||||
includeStatus?: boolean;
|
||||
};
|
||||
|
||||
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
||||
? hostsResponse.data
|
||||
: [];
|
||||
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||
}
|
||||
|
||||
export async function getSSHHosts(
|
||||
options: GetSSHHostsOptions = {},
|
||||
): Promise<SSHHostWithStatus[]> {
|
||||
const includeStatus = options.includeStatus !== false;
|
||||
|
||||
try {
|
||||
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
||||
|
||||
if (!includeStatus) {
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: "unknown",
|
||||
}));
|
||||
}
|
||||
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
@@ -45,9 +67,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
const response = await sshHostApi.post("/db/host", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.post("/db/host", hostData);
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create SSH host");
|
||||
@@ -67,9 +91,11 @@ export async function updateSSHHost(
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
}
|
||||
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update SSH host");
|
||||
@@ -103,6 +129,7 @@ export async function bulkImportSSHHosts(
|
||||
overwrite,
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "bulk import SSH hosts");
|
||||
@@ -125,6 +152,7 @@ export async function importSSHConfigHosts(
|
||||
content,
|
||||
overwrite,
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "import SSH config hosts");
|
||||
@@ -155,6 +183,7 @@ export async function bulkUpdateSSHHosts(
|
||||
hostIds,
|
||||
updates,
|
||||
});
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "bulk update SSH hosts");
|
||||
@@ -166,6 +195,7 @@ export async function deleteSSHHost(
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH host");
|
||||
|
||||
@@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import {
|
||||
removeSilentSigninFromSearch,
|
||||
shouldTriggerSilentSignin,
|
||||
@@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||
const [resetTempToken, setResetTempToken] = useState("");
|
||||
|
||||
const [language, setLanguage] = useState(
|
||||
() => localStorage.getItem("i18nextLng") ?? "en",
|
||||
const [language, setLanguage] = useState(() =>
|
||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||
);
|
||||
|
||||
function handleLanguageChange(code: string) {
|
||||
setLanguage(code);
|
||||
localStorage.setItem("i18nextLng", code);
|
||||
i18n.changeLanguage(code);
|
||||
void changeAppLanguage(code)
|
||||
.then((language) => setLanguage(language))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
|
||||
@@ -531,6 +531,10 @@ function HostStatusCard({
|
||||
);
|
||||
}
|
||||
|
||||
function isStatusCheckEnabled(host: Host): boolean {
|
||||
return host.statsConfig?.statusCheckEnabled !== false;
|
||||
}
|
||||
|
||||
function RecentActivityCard({
|
||||
activity,
|
||||
hosts,
|
||||
@@ -797,6 +801,7 @@ function CardItem({
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: {
|
||||
slot: CardSlot;
|
||||
editMode: boolean;
|
||||
@@ -826,6 +831,7 @@ function CardItem({
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading?: boolean;
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -922,6 +928,7 @@ function CardItem({
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
isVisible={isVisible}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
@@ -1051,6 +1058,7 @@ type PanelColumnProps = {
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
statusLoading: boolean;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
function PanelColumn({
|
||||
@@ -1082,6 +1090,7 @@ function PanelColumn({
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible = true,
|
||||
}: PanelColumnProps) {
|
||||
const { t } = useTranslation();
|
||||
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
||||
@@ -1138,6 +1147,7 @@ function PanelColumn({
|
||||
onAddServiceLink={onAddServiceLink}
|
||||
onDeleteServiceLink={onDeleteServiceLink}
|
||||
statusLoading={statusLoading}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -1188,9 +1198,12 @@ function ColumnDivider({
|
||||
export function DashboardTab({
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
isVisible = true,
|
||||
}: {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
/** When false, pause dashboard metrics refresh while the tab stays mounted. */
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { initialLoadComplete } = useServerStatus();
|
||||
@@ -1283,6 +1296,7 @@ export function DashboardTab({
|
||||
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||
>(new Map());
|
||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||
|
||||
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
||||
let statuses: Record<number, { status?: string }> = {};
|
||||
@@ -1342,8 +1356,11 @@ export function DashboardTab({
|
||||
const load = async () => {
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||
if (mounted) setHosts(mapped);
|
||||
fetchMetrics(mapped).catch(() => {});
|
||||
if (isVisible) {
|
||||
fetchMetrics(statusHosts).catch(() => {});
|
||||
}
|
||||
};
|
||||
load();
|
||||
|
||||
@@ -1393,28 +1410,37 @@ export function DashboardTab({
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (!isVisible) {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}
|
||||
|
||||
const metricsInterval = setInterval(async () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||
if (mounted) setHosts(mapped);
|
||||
fetchMetrics(mapped).catch(() => {});
|
||||
fetchMetrics(statusHosts).catch(() => {});
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(metricsInterval);
|
||||
};
|
||||
}, [fetchMetrics]);
|
||||
}, [fetchMetrics, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewerSessionsRef.current.size === 0) return;
|
||||
if (!isVisible || viewerSessionsRef.current.size === 0) return;
|
||||
const heartbeat = setInterval(async () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
for (const [, sessionId] of viewerSessionsRef.current) {
|
||||
sendMetricsHeartbeat(sessionId).catch(() => {});
|
||||
}
|
||||
}, 30000);
|
||||
return () => clearInterval(heartbeat);
|
||||
}, [hostMetrics]);
|
||||
}, [hostMetrics, isVisible]);
|
||||
|
||||
const handleClearActivity = async () => {
|
||||
try {
|
||||
@@ -1587,6 +1613,7 @@ export function DashboardTab({
|
||||
onAddServiceLink: handleAddServiceLink,
|
||||
onDeleteServiceLink: handleDeleteServiceLink,
|
||||
statusLoading,
|
||||
isVisible,
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1707,7 +1734,7 @@ export function DashboardTab({
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard
|
||||
hosts={hosts}
|
||||
hosts={statusCheckHosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
isAdmin={isAdmin}
|
||||
@@ -1726,6 +1753,7 @@ export function DashboardTab({
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
isVisible={isVisible}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,8 @@ interface NetworkGraphCardProps {
|
||||
rightSidebarWidth?: number;
|
||||
embedded?: boolean;
|
||||
onOpenInNewTab?: () => void;
|
||||
/** When false, pause status refresh while the surface stays mounted. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||
@@ -195,6 +197,7 @@ function buildNodeSvg(
|
||||
export function NetworkGraphCard({
|
||||
embedded = true,
|
||||
onOpenInNewTab,
|
||||
isVisible = true,
|
||||
}: NetworkGraphCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addTab } = useTabsSafe();
|
||||
@@ -269,8 +272,19 @@ export function NetworkGraphCard({
|
||||
}, [hostMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
if (statusIntervalRef.current) {
|
||||
clearInterval(statusIntervalRef.current);
|
||||
statusIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadData();
|
||||
statusIntervalRef.current = setInterval(updateHostStatuses, 30000);
|
||||
statusIntervalRef.current = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
updateHostStatuses();
|
||||
}, 30000);
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
@@ -293,7 +307,7 @@ export function NetworkGraphCard({
|
||||
document.removeEventListener("mousedown", onClickOutside, true);
|
||||
themeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [isVisible]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -335,7 +335,10 @@ function DockerManagerInner({
|
||||
};
|
||||
|
||||
pollContainers();
|
||||
const interval = setInterval(pollContainers, 5000);
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void pollContainers();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -50,7 +50,10 @@ export function ContainerStats({
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 2000);
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void fetchStats();
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStats]);
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ export function LogViewer({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 3000);
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void fetchLogs();
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ function FileManagerContent({
|
||||
initialPath,
|
||||
onClose,
|
||||
onOpenTerminalTab,
|
||||
isVisible = true,
|
||||
}: FileManagerProps) {
|
||||
const { openWindow } = useWindowManager();
|
||||
const { t } = useTranslation();
|
||||
@@ -268,7 +269,7 @@ function FileManagerContent({
|
||||
}, [currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
if (sshSessionId && isVisible) {
|
||||
startKeepalive();
|
||||
} else {
|
||||
stopKeepalive();
|
||||
@@ -277,7 +278,7 @@ function FileManagerContent({
|
||||
return () => {
|
||||
stopKeepalive();
|
||||
};
|
||||
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
||||
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
|
||||
|
||||
const initialFileOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -3105,6 +3106,7 @@ function FileManagerInner({
|
||||
initialPath,
|
||||
onClose,
|
||||
onOpenTerminalTab,
|
||||
isVisible = true,
|
||||
}: FileManagerProps) {
|
||||
return (
|
||||
<WindowManager>
|
||||
@@ -3114,6 +3116,7 @@ function FileManagerInner({
|
||||
initialPath={initialPath}
|
||||
onClose={onClose}
|
||||
onOpenTerminalTab={onOpenTerminalTab}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</WindowManager>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
Folder,
|
||||
@@ -185,6 +193,12 @@ export function FileManagerGrid({
|
||||
const { t } = useTranslation();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [gridCols, setGridCols] = useState(4);
|
||||
|
||||
const LIST_ROW_H = 41;
|
||||
const GRID_ROW_H = 112;
|
||||
const LIST_HEADER_H = 33;
|
||||
const CONTENT_PAD = 16;
|
||||
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
type: "none",
|
||||
@@ -192,6 +206,52 @@ export function FileManagerGrid({
|
||||
counter: 0,
|
||||
});
|
||||
|
||||
// Responsive column count for grid virtualization (matches Tailwind breakpoints roughly).
|
||||
useEffect(() => {
|
||||
if (viewMode !== "grid") return;
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updateCols = () => {
|
||||
const w = el.clientWidth - CONTENT_PAD * 2;
|
||||
// gap-4 (16px) + ~min cell 96px
|
||||
const n = Math.max(2, Math.min(8, Math.floor((w + 16) / 112)));
|
||||
setGridCols(n);
|
||||
};
|
||||
updateCols();
|
||||
const ro = new ResizeObserver(updateCols);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [viewMode]);
|
||||
|
||||
const gridRowCount = useMemo(
|
||||
() => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0),
|
||||
[viewMode, files.length, gridCols],
|
||||
);
|
||||
|
||||
const listVirtualizer = useVirtualizer({
|
||||
count: viewMode === "list" ? files.length : 0,
|
||||
getScrollElement: () => gridRef.current,
|
||||
estimateSize: () => LIST_ROW_H,
|
||||
overscan: 16,
|
||||
getItemKey: (index) => files[index]?.path ?? index,
|
||||
enabled: viewMode === "list" && files.length > 0,
|
||||
});
|
||||
|
||||
const gridVirtualizer = useVirtualizer({
|
||||
count: gridRowCount,
|
||||
getScrollElement: () => gridRef.current,
|
||||
estimateSize: () => GRID_ROW_H,
|
||||
overscan: 6,
|
||||
getItemKey: (index) => `row-${index}`,
|
||||
enabled: viewMode === "grid" && files.length > 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (viewMode === "list") listVirtualizer.measure();
|
||||
else gridVirtualizer.measure();
|
||||
}, [viewMode, files.length, editingFile?.path, createIntent, gridCols]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (dragState.type === "internal" && dragState.files.length > 0) {
|
||||
@@ -442,6 +502,77 @@ export function FileManagerGrid({
|
||||
setSelectionRect({ x, y, width, height });
|
||||
|
||||
if (gridRef.current) {
|
||||
const selectionBox = {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + width,
|
||||
bottom: y + height,
|
||||
};
|
||||
|
||||
// Virtual list: only visible DOM nodes exist. For list mode compute
|
||||
// selection from scroll position + fixed row height so drag-select
|
||||
// still covers off-screen rows.
|
||||
if (viewMode === "list" && files.length > 0) {
|
||||
const scrollTop = gridRef.current.scrollTop;
|
||||
const createExtra = createIntent ? LIST_ROW_H : 0;
|
||||
const contentTop =
|
||||
selectionBox.top +
|
||||
scrollTop -
|
||||
CONTENT_PAD -
|
||||
LIST_HEADER_H -
|
||||
createExtra;
|
||||
const contentBottom =
|
||||
selectionBox.bottom +
|
||||
scrollTop -
|
||||
CONTENT_PAD -
|
||||
LIST_HEADER_H -
|
||||
createExtra;
|
||||
const startIdx = Math.max(0, Math.floor(contentTop / LIST_ROW_H));
|
||||
const endIdx = Math.min(
|
||||
files.length - 1,
|
||||
Math.ceil(contentBottom / LIST_ROW_H),
|
||||
);
|
||||
if (endIdx >= startIdx) {
|
||||
onSelectionChange(files.slice(startIdx, endIdx + 1));
|
||||
} else {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewMode === "grid" && files.length > 0) {
|
||||
const scrollTop = gridRef.current.scrollTop;
|
||||
const createExtra = createIntent ? GRID_ROW_H : 0;
|
||||
const contentTop =
|
||||
selectionBox.top + scrollTop - CONTENT_PAD - createExtra;
|
||||
const contentBottom =
|
||||
selectionBox.bottom + scrollTop - CONTENT_PAD - createExtra;
|
||||
const startRow = Math.max(0, Math.floor(contentTop / GRID_ROW_H));
|
||||
const endRow = Math.min(
|
||||
gridRowCount - 1,
|
||||
Math.ceil(contentBottom / GRID_ROW_H),
|
||||
);
|
||||
// Column range from X within padded content.
|
||||
const contentLeft = selectionBox.left - CONTENT_PAD;
|
||||
const contentRight = selectionBox.right - CONTENT_PAD;
|
||||
const cellW =
|
||||
(gridRef.current.clientWidth - CONTENT_PAD * 2 + 16) / gridCols;
|
||||
const startCol = Math.max(0, Math.floor(contentLeft / cellW));
|
||||
const endCol = Math.min(
|
||||
gridCols - 1,
|
||||
Math.ceil(contentRight / cellW),
|
||||
);
|
||||
const selected: FileItem[] = [];
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
const idx = r * gridCols + c;
|
||||
if (idx >= 0 && idx < files.length) selected.push(files[idx]);
|
||||
}
|
||||
}
|
||||
onSelectionChange(selected);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileElements =
|
||||
gridRef.current.querySelectorAll("[data-file-path]");
|
||||
const selectedPaths: string[] = [];
|
||||
@@ -457,13 +588,6 @@ export function FileManagerGrid({
|
||||
bottom: elementRect.bottom - containerRect.top,
|
||||
};
|
||||
|
||||
const selectionBox = {
|
||||
left: x,
|
||||
top: y,
|
||||
right: x + width,
|
||||
bottom: y + height,
|
||||
};
|
||||
|
||||
const intersects = !(
|
||||
relativeElementRect.right < selectionBox.left ||
|
||||
relativeElementRect.left > selectionBox.right ||
|
||||
@@ -486,7 +610,16 @@ export function FileManagerGrid({
|
||||
}
|
||||
}
|
||||
},
|
||||
[isSelecting, selectionStart, files, onSelectionChange],
|
||||
[
|
||||
isSelecting,
|
||||
selectionStart,
|
||||
files,
|
||||
onSelectionChange,
|
||||
viewMode,
|
||||
createIntent,
|
||||
gridCols,
|
||||
gridRowCount,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
@@ -819,19 +952,48 @@ export function FileManagerGrid({
|
||||
</span>
|
||||
</div>
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{createIntent && (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<CreateIntentGridItem
|
||||
intent={createIntent}
|
||||
onConfirm={onConfirmCreate}
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: gridVirtualizer.getTotalSize() }}
|
||||
>
|
||||
{gridVirtualizer.getVirtualItems().map((vRow) => {
|
||||
const start = vRow.index * gridCols;
|
||||
const rowFiles = files.slice(start, start + gridCols);
|
||||
return (
|
||||
<div
|
||||
key={vRow.key}
|
||||
data-index={vRow.index}
|
||||
ref={gridVirtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid gap-4 pb-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{rowFiles.map((file) => {
|
||||
const isSelected = selectedFiles.some(
|
||||
(f) => f.path === file.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
@@ -839,11 +1001,13 @@ export function FileManagerGrid({
|
||||
draggable={true}
|
||||
className={cn(
|
||||
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
|
||||
isSelected && "bg-accent-brand/10 border-accent-brand/40",
|
||||
isSelected &&
|
||||
"bg-accent-brand/10 border-accent-brand/40",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
"opacity-50",
|
||||
dragState.files.some(
|
||||
(f) => f.path === file.path,
|
||||
) && "opacity-50",
|
||||
)}
|
||||
title={file.name}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
@@ -861,14 +1025,15 @@ export function FileManagerGrid({
|
||||
<div className="relative mb-2 pointer-events-none">
|
||||
{getFileIcon(file, viewMode)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col items-center pointer-events-none">
|
||||
{editingFile?.path === file.path ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setEditingName(e.target.value)
|
||||
}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
onBlur={handleEditConfirm}
|
||||
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
|
||||
@@ -903,6 +1068,11 @@ export function FileManagerGrid({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10">
|
||||
@@ -952,18 +1122,31 @@ export function FileManagerGrid({
|
||||
onCancel={onCancelCreate}
|
||||
/>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: listVirtualizer.getTotalSize() }}
|
||||
>
|
||||
{listVirtualizer.getVirtualItems().map((vItem) => {
|
||||
const file = files[vItem.index];
|
||||
if (!file) return null;
|
||||
const isSelected = selectedFiles.some(
|
||||
(f) => f.path === file.path,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={listVirtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-file-path={file.path}
|
||||
draggable={true}
|
||||
className={cn(
|
||||
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 last:border-0 rounded-none select-none transition-colors",
|
||||
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors",
|
||||
isSelected && "bg-accent-brand/10",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||
@@ -1035,9 +1218,11 @@ export function FileManagerGrid({
|
||||
{file.permissions || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelecting && selectionRect && (
|
||||
|
||||
@@ -71,9 +71,34 @@ export function TransferMonitor() {
|
||||
}
|
||||
};
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void reconcileTransfers();
|
||||
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
start();
|
||||
};
|
||||
|
||||
void reconcileTransfers();
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [t, formatTransferMetrics]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -407,7 +407,7 @@ export function FileViewer({
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="flex-shrink-0 bg-card border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
||||
{fileTypeInfo.icon}
|
||||
@@ -434,7 +434,7 @@ export function FileViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface FileManagerProps {
|
||||
initialPath?: string;
|
||||
onClose?: () => void;
|
||||
onOpenTerminalTab?: (path?: string) => void;
|
||||
/** When false, pause keepalive while the tab stays mounted in the background. */
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
@@ -12,6 +12,11 @@ import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
isFirefoxBrowser,
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
} from "./guacamole-clipboard.ts";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
@@ -338,6 +343,32 @@ export const GuacamoleDisplay = forwardRef<
|
||||
displayElement.setAttribute("tabindex", "0");
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
const useNativePasteFallback = isFirefoxBrowser();
|
||||
if (useNativePasteFallback) {
|
||||
displayElement.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (isPasteShortcut(event)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
displayElement.addEventListener(
|
||||
"paste",
|
||||
(event) => {
|
||||
if (clientRef.current !== client) return;
|
||||
const text = event.clipboardData?.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pasteTextToRemote(client, text);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
display.onresize = () => {
|
||||
if (!isMountedRef.current) return;
|
||||
rescaleDisplay(true);
|
||||
@@ -576,7 +607,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
if (!client || !navigator.clipboard?.readText) return;
|
||||
if (!client || isFirefoxBrowser() || !navigator.clipboard?.readText) return;
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
isFirefoxBrowser,
|
||||
isPasteShortcut,
|
||||
pasteTextToRemote,
|
||||
type GuacamoleClipboardClient,
|
||||
} from "./guacamole-clipboard.js";
|
||||
|
||||
describe("Guacamole Firefox clipboard fallback", () => {
|
||||
it("only enables the native paste path for Firefox", () => {
|
||||
expect(
|
||||
isFirefoxBrowser(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFirefoxBrowser(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes Ctrl+V and Command+V without intercepting Alt+V", () => {
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "V",
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
altKey: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasteShortcut({
|
||||
key: "v",
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
altKey: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the remote clipboard before sending Ctrl+V", () => {
|
||||
const events: string[] = [];
|
||||
const client: GuacamoleClipboardClient = {
|
||||
createClipboardStream: vi.fn((mimetype: string) => {
|
||||
events.push(`stream:${mimetype}`);
|
||||
return {
|
||||
sendBlob: () => events.push("blob"),
|
||||
sendEnd: () => events.push("end"),
|
||||
};
|
||||
}),
|
||||
sendKeyEvent: vi.fn((pressed: number, keysym: number) => {
|
||||
events.push(`key:${pressed}:${keysym}`);
|
||||
}),
|
||||
};
|
||||
|
||||
pasteTextToRemote(client, "Firefox clipboard");
|
||||
|
||||
expect(events).toEqual([
|
||||
"stream:text/plain",
|
||||
"blob",
|
||||
"end",
|
||||
"key:1:65507",
|
||||
"key:1:118",
|
||||
"key:0:118",
|
||||
"key:0:65507",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import Guacamole from "guacamole-common-js";
|
||||
|
||||
const CONTROL_LEFT_KEYSYM = 0xffe3;
|
||||
const V_KEYSYM = 0x76;
|
||||
|
||||
interface ClipboardOutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
export interface GuacamoleClipboardClient {
|
||||
createClipboardStream(mimetype: string): ClipboardOutputStream;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
}
|
||||
|
||||
export function isFirefoxBrowser(userAgent = navigator.userAgent): boolean {
|
||||
return /(?:Firefox|FxiOS)\//.test(userAgent);
|
||||
}
|
||||
|
||||
export function isPasteShortcut(
|
||||
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
|
||||
): boolean {
|
||||
return (
|
||||
!event.altKey &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "v"
|
||||
);
|
||||
}
|
||||
|
||||
export function pasteTextToRemote(
|
||||
client: GuacamoleClipboardClient,
|
||||
text: string,
|
||||
): void {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
|
||||
client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM);
|
||||
client.sendKeyEvent(1, V_KEYSYM);
|
||||
client.sendKeyEvent(0, V_KEYSYM);
|
||||
client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* setInterval that pauses while the document is hidden and fires once on resume.
|
||||
* Returns a cleanup function for useEffect.
|
||||
*/
|
||||
export function runVisibleInterval(
|
||||
tick: () => void,
|
||||
intervalMs: number,
|
||||
): () => void {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null || intervalMs <= 0) return;
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
tick();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
start();
|
||||
};
|
||||
|
||||
if (
|
||||
typeof document === "undefined" ||
|
||||
document.visibilityState !== "hidden"
|
||||
) {
|
||||
start();
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -61,8 +61,35 @@ function AlertFeedWidget({
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void fetchData();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [maxItems, showAcknowledged]);
|
||||
|
||||
const handleAck = async (id: number) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function getMonthData(year: number, month: number, startOnMonday: boolean) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
@@ -27,8 +28,7 @@ function CalendarWidget({
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(iv);
|
||||
return runVisibleInterval(() => setNow(new Date()), 60_000);
|
||||
}, []);
|
||||
|
||||
const { daysInMonth, offset } = getMonthData(
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
|
||||
const { timezone, showSeconds, format } = config;
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, []);
|
||||
// Minute-level is enough without seconds; pause when the tab is backgrounded.
|
||||
usePageVisibleInterval(
|
||||
() => setNow(new Date()),
|
||||
showSeconds ? 1_000 : 30_000,
|
||||
);
|
||||
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: "2-digit",
|
||||
|
||||
@@ -69,8 +69,32 @@ function CountdownWidget({
|
||||
}
|
||||
};
|
||||
update();
|
||||
const iv = setInterval(update, 1000);
|
||||
return () => clearInterval(iv);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(update, 1000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
update();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [targetDate]);
|
||||
|
||||
const titleBar = (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { homepageApi } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function resolvePath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc: unknown, key) => {
|
||||
@@ -70,8 +71,9 @@ function CustomApiWidget({
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, interval * 1000);
|
||||
return () => clearInterval(iv);
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, interval * 1000);
|
||||
}, [url, interval]);
|
||||
|
||||
const accent =
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getRecentActivity } from "@/api/dashboard-api";
|
||||
import type { RecentActivityItem } from "@/api/dashboard-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
@@ -43,8 +44,9 @@ function DockerActivityWidget({
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
}, [maxItems]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
@@ -44,39 +44,27 @@ function HostGridWidget({
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
// getSSHHosts already attaches cached server status — no second /status call.
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [allHosts, statuses] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getAllServerStatuses().catch(
|
||||
() => ({}) as Record<number, { status: string }>,
|
||||
),
|
||||
]);
|
||||
const allHosts = await getSSHHosts();
|
||||
const filtered =
|
||||
hostIds.length > 0
|
||||
? allHosts.filter((h) => hostIds.includes(h.id))
|
||||
: allHosts;
|
||||
const withStatus = filtered.map((h) => ({
|
||||
...h,
|
||||
status:
|
||||
(statuses as Record<number, { status: string }>)[h.id]?.status ??
|
||||
h.status ??
|
||||
"unknown",
|
||||
}));
|
||||
setHosts(withStatus);
|
||||
setHosts(filtered);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 10_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [hostIds.join(",")]);
|
||||
|
||||
// Align with global status cadence; pause when the tab is hidden.
|
||||
usePageVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 30_000);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
|
||||
@@ -130,10 +130,37 @@ function HostStatusWidget({
|
||||
};
|
||||
|
||||
poll();
|
||||
const iv = setInterval(poll, 10000);
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
// Align with global status cadence; metrics do not need sub-10s when hidden.
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void poll();
|
||||
}, 30_000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
void poll();
|
||||
start();
|
||||
};
|
||||
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [hostId, needsMetrics]);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user