mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22124e1bc4 | |||
| 401ec7e8fc | |||
| 1ee4cacbe9 | |||
| fafff94e5e | |||
| 9d336bf9df | |||
| e1d1a3e53d | |||
| 4b2a60a854 | |||
| 3d42a2c09c | |||
| 9ed77d42d4 | |||
| c7a9063a72 | |||
| a0ae397e63 | |||
| b0598294a6 | |||
| 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:
|
options:
|
||||||
- Development
|
- Development
|
||||||
- Production
|
- Production
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -29,6 +33,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -37,8 +46,12 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Resolve source revision
|
||||||
|
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -98,7 +111,7 @@ jobs:
|
|||||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||||
labels: |
|
labels: |
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
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 }}
|
org.opencontainers.image.created=${{ github.run_id }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
- file
|
||||||
- release
|
- release
|
||||||
- submit
|
- submit
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build (defaults to the workflow ref)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
build_type:
|
build_type:
|
||||||
@@ -38,6 +42,11 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: ""
|
default: ""
|
||||||
|
source_ref:
|
||||||
|
description: "Git ref/SHA to build"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
outputs:
|
outputs:
|
||||||
macos_universal_dmg_sha256:
|
macos_universal_dmg_sha256:
|
||||||
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
||||||
@@ -54,6 +63,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -144,6 +154,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -354,6 +365,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
@@ -580,6 +592,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -686,6 +699,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -823,6 +837,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
@@ -933,6 +948,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.source_ref || github.ref }}
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ jobs:
|
|||||||
|
|
||||||
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
|
||||||
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: [prep, merge-to-main]
|
needs: [prep, merge-to-main]
|
||||||
@@ -287,6 +287,7 @@ jobs:
|
|||||||
version: ${{ needs.prep.outputs.version }}
|
version: ${{ needs.prep.outputs.version }}
|
||||||
build_type: Production
|
build_type: Production
|
||||||
dry_run: ${{ inputs.mode == 'Dry run' }}
|
dry_run: ${{ inputs.mode == 'Dry run' }}
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
@@ -351,15 +352,17 @@ jobs:
|
|||||||
build_type: all
|
build_type: all
|
||||||
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
|
||||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
electron-submit:
|
electron-submit:
|
||||||
needs: [prep, electron-release]
|
needs: [prep, merge-to-main, electron-release]
|
||||||
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
|
||||||
uses: ./.github/workflows/electron.yml
|
uses: ./.github/workflows/electron.yml
|
||||||
with:
|
with:
|
||||||
build_type: all
|
build_type: all
|
||||||
artifact_destination: submit
|
artifact_destination: submit
|
||||||
|
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
cask-commit-back:
|
cask-commit-back:
|
||||||
|
|||||||
@@ -31,14 +31,12 @@
|
|||||||
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
<a href="https://donate.termix.site/"><img alt="Donate" src="https://img.shields.io/badge/Donate-Support%20Termix-F39044?style=flat&labelColor=1a1a1a" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Termix is free and open source. If you find it useful, consider [donating](https://donate.termix.site/) to help cover server costs and development time.
|
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 />
|
<br />
|
||||||
|
|
||||||
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
|
||||||
@@ -268,6 +266,8 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -279,6 +279,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4822:4822"
|
- "4822:4822"
|
||||||
|
volumes:
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
@@ -291,6 +293,13 @@ networks:
|
|||||||
driver: bridge
|
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 />
|
<br />
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Install dependencies
|
# Stage 1: Install dependencies
|
||||||
FROM node:24-slim AS deps
|
FROM node:26-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
|
|||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Production dependencies only
|
# Stage 4: Production dependencies only
|
||||||
FROM node:24-slim AS production-deps
|
FROM node:26-slim AS production-deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||||
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
|||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 5: Final optimized image
|
# Stage 5: Final optimized image
|
||||||
FROM node:24-slim
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
GUACD_HOST: "guacd-dev"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd-dev
|
- guacd-dev
|
||||||
networks:
|
networks:
|
||||||
@@ -21,6 +23,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd-dev
|
container_name: guacd-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-dev-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-dev-net
|
- termix-dev-net
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
GUACD_HOST: "guacd"
|
GUACD_HOST: "guacd"
|
||||||
|
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
|
||||||
depends_on:
|
depends_on:
|
||||||
- guacd
|
- guacd
|
||||||
networks:
|
networks:
|
||||||
@@ -19,6 +20,8 @@ services:
|
|||||||
image: guacamole/guacd:1.6.0
|
image: guacamole/guacd:1.6.0
|
||||||
container_name: guacd
|
container_name: guacd
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- termix-data:/termix-data
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -235,6 +235,18 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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(/.*)?$ {
|
location ~ ^/c2s-tunnel-presets(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -433,8 +445,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
@@ -676,8 +688,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_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-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
@@ -677,8 +677,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
|
|||||||
+1
-1
@@ -1397,7 +1397,7 @@ ipcMain.handle(
|
|||||||
|
|
||||||
server.once("error", fail);
|
server.once("error", fail);
|
||||||
|
|
||||||
server.listen(callbackPort, "127.0.0.1", async () => {
|
server.listen(callbackPort, "localhost", async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(authUrl);
|
await shell.openExternal(authUrl);
|
||||||
} catch (error) {
|
} 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 .",
|
"format:check": "prettier --check .",
|
||||||
"biome:check": "biome check biome.json package.json",
|
"biome:check": "biome check biome.json package.json",
|
||||||
"biome:fix": "biome check --write 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",
|
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix .",
|
||||||
@@ -45,8 +45,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
"axios": "^1.18.0",
|
"axios": "^1.18.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.3.0",
|
||||||
@@ -58,28 +59,28 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"guacamole-lite": "^1.2.0",
|
"guacamole-lite": "^1.2.0",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"js-yaml": "^5.0.0",
|
"js-yaml": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.42.2",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.15",
|
"nanoid": "^5.1.16",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"socks": "^2.8.7",
|
"socks": "^2.8.7",
|
||||||
"speakeasy": "^2.0.0",
|
"speakeasy": "^2.0.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.7.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.1",
|
"@biomejs/biome": "2.5.2",
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/search": "^6.7.1",
|
"@codemirror/search": "^6.7.1",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.43.1",
|
"@codemirror/view": "^6.43.5",
|
||||||
"@commitlint/cli": "^21.0.2",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.2",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
@@ -91,23 +92,23 @@
|
|||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/source-code-pro": "^5.2.7",
|
"@fontsource/source-code-pro": "^5.2.7",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.13",
|
"@radix-ui/react-accordion": "^1.2.15",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.18",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.6",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.18",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.11",
|
||||||
"@radix-ui/react-popover": "^1.1.16",
|
"@radix-ui/react-popover": "^1.1.18",
|
||||||
"@radix-ui/react-progress": "^1.1.9",
|
"@radix-ui/react-progress": "^1.1.11",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||||
"@radix-ui/react-select": "^2.3.1",
|
"@radix-ui/react-select": "^2.3.2",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.11",
|
||||||
"@radix-ui/react-slider": "^1.4.1",
|
"@radix-ui/react-slider": "^1.4.2",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.1",
|
"@radix-ui/react-switch": "^1.3.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.16",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.11",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -130,7 +131,7 @@
|
|||||||
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
"@uiw/codemirror-extensions-langs": "^4.25.9",
|
||||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||||
"@uiw/react-codemirror": "^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/coverage-v8": "^4.1.9",
|
||||||
"@vitest/ui": "^4.1.9",
|
"@vitest/ui": "^4.1.9",
|
||||||
"@xterm/addon-clipboard": "^0.2.0",
|
"@xterm/addon-clipboard": "^0.2.0",
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"cytoscape": "^3.34.0",
|
"cytoscape": "^3.34.0",
|
||||||
"electron": "^42.4.1",
|
"electron": "^43.0.0",
|
||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.5.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -152,13 +153,13 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"guacamole-common-js": "^1.5.0",
|
"guacamole-common-js": "^1.5.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^17.0.8",
|
"lint-staged": "^17.0.8",
|
||||||
"lucide-react": "^1.20.0",
|
"lucide-react": "^1.20.0",
|
||||||
"prettier": "3.8.4",
|
"prettier": "3.8.4",
|
||||||
"radix-ui": "^1.6.0",
|
"radix-ui": "^1.6.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sharp": "^0.35.2",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
|||||||
@@ -26,10 +26,31 @@ if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
|||||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||||
|
|
||||||
// Patch 1: version acceptance list
|
// Patch 1: protocol version negotiation.
|
||||||
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||||
const newVersionCheck =
|
// versions Termix can handle, and conservatively answer future 1.x versions as
|
||||||
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
|
// 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
|
// 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') {";
|
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||||
@@ -105,17 +126,23 @@ const newReadyHandler =
|
|||||||
|
|
||||||
let patched = false;
|
let patched = false;
|
||||||
|
|
||||||
if (!guacdClientContent.includes(newVersionCheck)) {
|
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
|
||||||
if (!guacdClientContent.includes(oldVersionCheck)) {
|
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(
|
||||||
|
oldPatchedVersionBlock,
|
||||||
|
newVersionBlock,
|
||||||
|
);
|
||||||
|
} else if (guacdClientContent.includes(oldVersionBlock)) {
|
||||||
|
guacdClientContent = guacdClientContent.replace(
|
||||||
|
oldVersionBlock,
|
||||||
|
newVersionBlock,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Version check target not found, skipping",
|
"[patch-guacamole-lite] Version check target not found, skipping",
|
||||||
);
|
);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
guacdClientContent = guacdClientContent.replace(
|
|
||||||
oldVersionCheck,
|
|
||||||
newVersionCheck,
|
|
||||||
);
|
|
||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
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", () => {
|
describe("patch-guacamole-lite", () => {
|
||||||
it("handles guacd dynamic argument requests", () => {
|
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(['blob'");
|
||||||
expect(content).toContain("this.sendInstruction(['end'");
|
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,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
@@ -564,12 +566,30 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
const timeoutRow = sqlite
|
||||||
databaseLogger.info("Open tabs cleared on startup", {
|
.prepare(
|
||||||
operation: "db_init_open_tabs_cleanup",
|
"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) {
|
} 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",
|
operation: "db_init_open_tabs_cleanup_failed",
|
||||||
error: e,
|
error: e,
|
||||||
});
|
});
|
||||||
@@ -695,6 +715,17 @@ const addColumnIfNotExists = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const migrateSchema = () => {
|
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", "theme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "accent_color", "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_enabled", "INTEGER NOT NULL DEFAULT 0");
|
||||||
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
|
||||||
|
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
|
||||||
|
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
|
||||||
|
|
||||||
sqlite.exec(`
|
sqlite.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -1553,6 +1588,8 @@ const migrateSchema = () => {
|
|||||||
commands TEXT,
|
commands TEXT,
|
||||||
dangerous_actions TEXT,
|
dangerous_actions TEXT,
|
||||||
recording_path TEXT,
|
recording_path TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
format TEXT NOT NULL DEFAULT 'text',
|
||||||
terminated_by_owner INTEGER DEFAULT 0,
|
terminated_by_owner INTEGER DEFAULT 0,
|
||||||
termination_reason TEXT,
|
termination_reason TEXT,
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
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(),
|
jwtToken: text("jwt_token").notNull(),
|
||||||
deviceType: text("device_type").notNull(),
|
deviceType: text("device_type").notNull(),
|
||||||
deviceInfo: text("device_info").notNull(),
|
deviceInfo: text("device_info").notNull(),
|
||||||
|
oidcSub: text("oidc_sub"),
|
||||||
|
oidcSid: text("oidc_sid"),
|
||||||
|
ssoProviderId: integer("sso_provider_id"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
@@ -657,6 +660,8 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
dangerousActions: text("dangerous_actions"),
|
dangerousActions: text("dangerous_actions"),
|
||||||
|
|
||||||
recordingPath: text("recording_path"),
|
recordingPath: text("recording_path"),
|
||||||
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
|
format: text("format").notNull().default("text"),
|
||||||
|
|
||||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { execSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
@@ -39,7 +39,7 @@ function getCertInfo(): {
|
|||||||
} {
|
} {
|
||||||
const certFile = path.join(SSL_DIR, "termix.crt");
|
const certFile = path.join(SSL_DIR, "termix.crt");
|
||||||
try {
|
try {
|
||||||
execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, {
|
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,8 +47,9 @@ function getCertInfo(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endDateRaw = execSync(
|
const endDateRaw = execFileSync(
|
||||||
`openssl x509 -in "${certFile}" -noout -enddate`,
|
"openssl",
|
||||||
|
["x509", "-in", certFile, "-noout", "-enddate"],
|
||||||
{ stdio: "pipe" },
|
{ stdio: "pipe" },
|
||||||
)
|
)
|
||||||
.toString()
|
.toString()
|
||||||
@@ -57,17 +58,25 @@ function getCertInfo(): {
|
|||||||
const expiresAt = new Date(endDateRaw).toISOString();
|
const expiresAt = new Date(endDateRaw).toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, {
|
execFileSync(
|
||||||
stdio: "pipe",
|
"openssl",
|
||||||
});
|
["x509", "-in", certFile, "-checkend", "0", "-noout"],
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "expired", expiresAt };
|
return { status: "expired", expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, {
|
execFileSync(
|
||||||
stdio: "pipe",
|
"openssl",
|
||||||
});
|
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
return { status: "valid", expiresAt };
|
return { status: "valid", expiresAt };
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "expiring", expiresAt };
|
return { status: "expiring", expiresAt };
|
||||||
@@ -265,7 +274,7 @@ export function registerAcmeSSLRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execSync("certbot --version", { stdio: "pipe" });
|
execFileSync("certbot", ["--version"], { stdio: "pipe" });
|
||||||
} catch {
|
} catch {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
@@ -278,13 +287,16 @@ export function registerAcmeSSLRoutes(
|
|||||||
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
||||||
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
||||||
|
|
||||||
const certbotDirFlags = [
|
const certbotDirArgs = [
|
||||||
`--config-dir "${CERTBOT_CONFIG_DIR}"`,
|
"--config-dir",
|
||||||
`--work-dir "${CERTBOT_WORK_DIR}"`,
|
CERTBOT_CONFIG_DIR,
|
||||||
`--logs-dir "${CERTBOT_LOGS_DIR}"`,
|
"--work-dir",
|
||||||
].join(" ");
|
CERTBOT_WORK_DIR,
|
||||||
|
"--logs-dir",
|
||||||
|
CERTBOT_LOGS_DIR,
|
||||||
|
];
|
||||||
|
|
||||||
let certbotCmd: string;
|
let certbotArgs: string[];
|
||||||
|
|
||||||
if (challengeType === "dns-cloudflare") {
|
if (challengeType === "dns-cloudflare") {
|
||||||
if (!cloudflareToken) {
|
if (!cloudflareToken) {
|
||||||
@@ -302,40 +314,39 @@ export function registerAcmeSSLRoutes(
|
|||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
|
|
||||||
certbotCmd = [
|
certbotArgs = [
|
||||||
"certbot",
|
|
||||||
"certonly",
|
"certonly",
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"--agree-tos",
|
"--agree-tos",
|
||||||
"--dns-cloudflare",
|
"--dns-cloudflare",
|
||||||
`--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`,
|
"--dns-cloudflare-credentials",
|
||||||
|
CLOUDFLARE_CREDENTIALS_FILE,
|
||||||
"--dns-cloudflare-propagation-seconds",
|
"--dns-cloudflare-propagation-seconds",
|
||||||
"30",
|
"30",
|
||||||
"-d",
|
"-d",
|
||||||
`"${domain}"`,
|
domain,
|
||||||
"--email",
|
"--email",
|
||||||
`"${email}"`,
|
email,
|
||||||
"--cert-name",
|
"--cert-name",
|
||||||
"termix",
|
"termix",
|
||||||
certbotDirFlags,
|
...certbotDirArgs,
|
||||||
].join(" ");
|
];
|
||||||
} else {
|
} else {
|
||||||
certbotCmd = [
|
certbotArgs = [
|
||||||
"certbot",
|
|
||||||
"certonly",
|
"certonly",
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"--agree-tos",
|
"--agree-tos",
|
||||||
"--webroot",
|
"--webroot",
|
||||||
"-w",
|
"-w",
|
||||||
`"${ACME_WEBROOT}"`,
|
ACME_WEBROOT,
|
||||||
"-d",
|
"-d",
|
||||||
`"${domain}"`,
|
domain,
|
||||||
"--email",
|
"--email",
|
||||||
`"${email}"`,
|
email,
|
||||||
"--cert-name",
|
"--cert-name",
|
||||||
"termix",
|
"termix",
|
||||||
certbotDirFlags,
|
...certbotDirArgs,
|
||||||
].join(" ");
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info("Requesting Let's Encrypt certificate", {
|
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||||
@@ -344,7 +355,10 @@ export function registerAcmeSSLRoutes(
|
|||||||
operation: "acme_cert_request",
|
operation: "acme_cert_request",
|
||||||
});
|
});
|
||||||
|
|
||||||
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
|
execFileSync("certbot", certbotArgs, {
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
|
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
|
||||||
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
CredentialBackend,
|
CredentialBackend,
|
||||||
} from "../../../types/index.js";
|
} from "../../../types/index.js";
|
||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
import type { Request, RequestHandler, Response, Router } from "express";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import ssh2Pkg from "ssh2";
|
import ssh2Pkg from "ssh2";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { hosts, sshCredentials } from "../db/schema.js";
|
import { hosts, sshCredentials } from "../db/schema.js";
|
||||||
@@ -87,6 +87,10 @@ async function deploySSHKeyToHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyPattern = keyParts[1];
|
const keyPattern = keyParts[1];
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
|
||||||
|
clearTimeout(checkTimeout);
|
||||||
|
return rejectCheck(new Error("Invalid public key data"));
|
||||||
|
}
|
||||||
|
|
||||||
conn.exec(
|
conn.exec(
|
||||||
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
|
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
|
||||||
@@ -192,6 +196,10 @@ async function deploySSHKeyToHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyPattern = keyParts[1];
|
const keyPattern = keyParts[1];
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
|
||||||
|
clearTimeout(verifyTimeout);
|
||||||
|
return rejectVerify(new Error("Invalid public key data"));
|
||||||
|
}
|
||||||
conn.exec(
|
conn.exec(
|
||||||
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
|
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
|
||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
@@ -432,7 +440,12 @@ export function registerCredentialDeployRoutes(
|
|||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshCredentials)
|
.from(sshCredentials)
|
||||||
.where(eq(sshCredentials.id, credentialId))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.id, credentialId),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1),
|
.limit(1),
|
||||||
"ssh_credentials",
|
"ssh_credentials",
|
||||||
userId,
|
userId,
|
||||||
@@ -462,7 +475,11 @@ export function registerCredentialDeployRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const targetHost = await SimpleDBOps.select(
|
const targetHost = await SimpleDBOps.select(
|
||||||
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
|
db
|
||||||
|
.select()
|
||||||
|
.from(hosts)
|
||||||
|
.where(and(eq(hosts.id, targetHostId), eq(hosts.userId, userId)))
|
||||||
|
.limit(1),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, asc, eq } from "drizzle-orm";
|
||||||
import { dashboardLogger } from "../../utils/logger.js";
|
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 { dashboardServiceLinks } from "../db/schema.js";
|
||||||
import { isNonEmptyString } from "./host-normalizers.js";
|
import { isNonEmptyString } from "./host-normalizers.js";
|
||||||
import {
|
import {
|
||||||
@@ -107,6 +107,7 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
|
||||||
res.status(201).json(created);
|
res.status(201).json(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dashboardLogger.error("Failed to create service link", 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" });
|
res.json({ message: "Service link deleted" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dashboardLogger.error("Failed to delete service link", err);
|
dashboardLogger.error("Failed to delete service link", err);
|
||||||
@@ -272,6 +274,7 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dashboardLogger.error("Failed to update service link", err);
|
dashboardLogger.error("Failed to update service link", err);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Request, Response } from "express";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
|
import { lookup } from "dns/promises";
|
||||||
|
import { BlockList, isIP } from "net";
|
||||||
import { homepageLogger } from "../../utils/logger.js";
|
import { homepageLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
export const homepageProxyRouter = express.Router();
|
export const homepageProxyRouter = express.Router();
|
||||||
@@ -15,22 +17,95 @@ const proxyCache = new Map<string, ProxyCacheEntry>();
|
|||||||
const CACHE_SIZE = 50;
|
const CACHE_SIZE = 50;
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
function fetchJson(url: string): Promise<unknown> {
|
const blockedAddresses = new BlockList();
|
||||||
|
for (const [network, prefix] of [
|
||||||
|
["0.0.0.0", 8],
|
||||||
|
["10.0.0.0", 8],
|
||||||
|
["100.64.0.0", 10],
|
||||||
|
["127.0.0.0", 8],
|
||||||
|
["169.254.0.0", 16],
|
||||||
|
["172.16.0.0", 12],
|
||||||
|
["192.168.0.0", 16],
|
||||||
|
["198.18.0.0", 15],
|
||||||
|
["224.0.0.0", 4],
|
||||||
|
["240.0.0.0", 4],
|
||||||
|
] as const) {
|
||||||
|
blockedAddresses.addSubnet(network, prefix, "ipv4");
|
||||||
|
}
|
||||||
|
for (const [network, prefix] of [
|
||||||
|
["::", 128],
|
||||||
|
["::1", 128],
|
||||||
|
["::ffff:0:0", 96],
|
||||||
|
["fc00::", 7],
|
||||||
|
["fe80::", 10],
|
||||||
|
["ff00::", 8],
|
||||||
|
] as const) {
|
||||||
|
blockedAddresses.addSubnet(network, prefix, "ipv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedAddress(address: string): boolean {
|
||||||
|
const family = isIP(address);
|
||||||
|
return (
|
||||||
|
family === 0 ||
|
||||||
|
blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePublicUrl(rawUrl: string): Promise<{
|
||||||
|
url: URL;
|
||||||
|
address: string;
|
||||||
|
}> {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||||
|
const addresses = isIP(hostname)
|
||||||
|
? [{ address: hostname }]
|
||||||
|
: await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
if (
|
||||||
|
addresses.length === 0 ||
|
||||||
|
addresses.some(({ address }) => isBlockedAddress(address))
|
||||||
|
) {
|
||||||
|
throw new Error("Private destinations are not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, address: addresses[0].address };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(rawUrl: string): Promise<unknown> {
|
||||||
|
const { url, address } = await resolvePublicUrl(rawUrl);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const mod = url.startsWith("https") ? https : http;
|
const mod = url.protocol === "https:" ? https : http;
|
||||||
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
const req = mod.get(
|
||||||
const chunks: Buffer[] = [];
|
{
|
||||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
protocol: url.protocol,
|
||||||
res.on("end", () => {
|
hostname: address,
|
||||||
try {
|
port: url.port || undefined,
|
||||||
const text = Buffer.concat(chunks).toString("utf-8");
|
path: `${url.pathname}${url.search}`,
|
||||||
resolve(JSON.parse(text));
|
headers: { Host: url.host },
|
||||||
} catch {
|
servername: url.protocol === "https:" ? url.hostname : undefined,
|
||||||
reject(new Error("Response is not valid JSON"));
|
timeout: FETCH_TIMEOUT_MS,
|
||||||
}
|
},
|
||||||
});
|
(res) => {
|
||||||
res.on("error", reject);
|
const chunks: Buffer[] = [];
|
||||||
});
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const text = Buffer.concat(chunks).toString("utf-8");
|
||||||
|
resolve(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Response is not valid JSON"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on("error", reject);
|
||||||
|
},
|
||||||
|
);
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => {
|
req.on("timeout", () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
|
|||||||
@@ -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 { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { parseSSHKey } from "../../utils/ssh-key-utils.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 {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
@@ -40,6 +43,10 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
|
|||||||
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
import { registerHostInternalRoutes } from "./host-internal-routes.js";
|
||||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||||
import { registerHostBulkRoutes } from "./host-bulk-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";
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -161,9 +168,10 @@ registerHostInternalRoutes(router);
|
|||||||
* description: Failed to save SSH data.
|
* description: Failed to save SSH data.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/db/host",
|
["/db/host", "/enroll"],
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
|
requireHostEnrollmentAccessForPath,
|
||||||
upload.single("key"),
|
upload.single("key"),
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -196,6 +204,10 @@ router.post(
|
|||||||
hostData = req.body;
|
hostData = req.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.path === "/enroll") {
|
||||||
|
hostData = applyHostEnrollmentDefaults(hostData);
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
connectionType,
|
connectionType,
|
||||||
name,
|
name,
|
||||||
@@ -259,6 +271,8 @@ router.post(
|
|||||||
rdpPort,
|
rdpPort,
|
||||||
vncPort,
|
vncPort,
|
||||||
telnetPort,
|
telnetPort,
|
||||||
|
rdpAuthType,
|
||||||
|
rdpCredentialId,
|
||||||
rdpUser,
|
rdpUser,
|
||||||
rdpPassword,
|
rdpPassword,
|
||||||
rdpDomain,
|
rdpDomain,
|
||||||
@@ -268,6 +282,8 @@ router.post(
|
|||||||
vncCredentialId,
|
vncCredentialId,
|
||||||
vncPassword,
|
vncPassword,
|
||||||
vncUser,
|
vncUser,
|
||||||
|
telnetAuthType,
|
||||||
|
telnetCredentialId,
|
||||||
telnetUser,
|
telnetUser,
|
||||||
telnetPassword,
|
telnetPassword,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -385,6 +401,11 @@ router.post(
|
|||||||
rdpPort: rdpPort || 3389,
|
rdpPort: rdpPort || 3389,
|
||||||
vncPort: vncPort || 5900,
|
vncPort: vncPort || 5900,
|
||||||
telnetPort: telnetPort || 23,
|
telnetPort: telnetPort || 23,
|
||||||
|
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||||
|
rdpCredentialId:
|
||||||
|
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||||
|
? rdpCredentialId
|
||||||
|
: null,
|
||||||
rdpUser: rdpUser || null,
|
rdpUser: rdpUser || null,
|
||||||
rdpDomain: rdpDomain || null,
|
rdpDomain: rdpDomain || null,
|
||||||
rdpSecurity: rdpSecurity || null,
|
rdpSecurity: rdpSecurity || null,
|
||||||
@@ -395,6 +416,11 @@ router.post(
|
|||||||
? vncCredentialId
|
? vncCredentialId
|
||||||
: null,
|
: null,
|
||||||
vncUser: vncUser || null,
|
vncUser: vncUser || null,
|
||||||
|
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||||
|
telnetCredentialId:
|
||||||
|
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||||
|
? telnetCredentialId
|
||||||
|
: null,
|
||||||
telnetUser: telnetUser || null,
|
telnetUser: telnetUser || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -433,7 +459,12 @@ router.post(
|
|||||||
sshDataObj.key = key || null;
|
sshDataObj.key = key || null;
|
||||||
sshDataObj.keyPassword = keyPassword || null;
|
sshDataObj.keyPassword = keyPassword || null;
|
||||||
sshDataObj.keyType = 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") {
|
} else if (effectiveAuthType === "agent") {
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
sshDataObj.key = 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
|
* @openapi
|
||||||
* /host/quick-connect:
|
* /host/quick-connect:
|
||||||
@@ -642,7 +734,7 @@ router.post(
|
|||||||
|
|
||||||
const cred = credentials[0];
|
const cred = credentials[0];
|
||||||
|
|
||||||
resolvedPassword = cred.password as string | undefined;
|
resolvedPassword = pickResolvedPassword(password, cred.password);
|
||||||
resolvedKey = cred.privateKey as string | undefined;
|
resolvedKey = cred.privateKey as string | undefined;
|
||||||
resolvedKeyPassword = cred.keyPassword as string | undefined;
|
resolvedKeyPassword = cred.keyPassword as string | undefined;
|
||||||
resolvedKeyType = cred.keyType as string | undefined;
|
resolvedKeyType = cred.keyType as string | undefined;
|
||||||
@@ -835,6 +927,8 @@ router.put(
|
|||||||
rdpPort,
|
rdpPort,
|
||||||
vncPort,
|
vncPort,
|
||||||
telnetPort,
|
telnetPort,
|
||||||
|
rdpAuthType,
|
||||||
|
rdpCredentialId,
|
||||||
rdpUser,
|
rdpUser,
|
||||||
rdpPassword,
|
rdpPassword,
|
||||||
rdpDomain,
|
rdpDomain,
|
||||||
@@ -844,6 +938,8 @@ router.put(
|
|||||||
vncCredentialId,
|
vncCredentialId,
|
||||||
vncPassword,
|
vncPassword,
|
||||||
vncUser,
|
vncUser,
|
||||||
|
telnetAuthType,
|
||||||
|
telnetCredentialId,
|
||||||
telnetUser,
|
telnetUser,
|
||||||
telnetPassword,
|
telnetPassword,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -958,6 +1054,11 @@ router.put(
|
|||||||
rdpPort: rdpPort || 3389,
|
rdpPort: rdpPort || 3389,
|
||||||
vncPort: vncPort || 5900,
|
vncPort: vncPort || 5900,
|
||||||
telnetPort: telnetPort || 23,
|
telnetPort: telnetPort || 23,
|
||||||
|
rdpAuthType: enableRdp ? rdpAuthType || null : null,
|
||||||
|
rdpCredentialId:
|
||||||
|
enableRdp && rdpAuthType === "credential" && rdpCredentialId
|
||||||
|
? rdpCredentialId
|
||||||
|
: null,
|
||||||
rdpUser: rdpUser || null,
|
rdpUser: rdpUser || null,
|
||||||
rdpDomain: rdpDomain || null,
|
rdpDomain: rdpDomain || null,
|
||||||
rdpSecurity: rdpSecurity || null,
|
rdpSecurity: rdpSecurity || null,
|
||||||
@@ -968,6 +1069,11 @@ router.put(
|
|||||||
? vncCredentialId
|
? vncCredentialId
|
||||||
: null,
|
: null,
|
||||||
vncUser: vncUser || null,
|
vncUser: vncUser || null,
|
||||||
|
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
|
||||||
|
telnetCredentialId:
|
||||||
|
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
|
||||||
|
? telnetCredentialId
|
||||||
|
: null,
|
||||||
telnetUser: telnetUser || null,
|
telnetUser: telnetUser || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1015,7 +1121,12 @@ router.put(
|
|||||||
if (keyType) {
|
if (keyType) {
|
||||||
sshDataObj.keyType = 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") {
|
} else if (effectiveAuthType === "agent") {
|
||||||
sshDataObj.password = null;
|
sshDataObj.password = null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
@@ -1065,6 +1176,7 @@ router.put(
|
|||||||
credentialId: hosts.credentialId,
|
credentialId: hosts.credentialId,
|
||||||
rdpCredentialId: hosts.rdpCredentialId,
|
rdpCredentialId: hosts.rdpCredentialId,
|
||||||
vncCredentialId: hosts.vncCredentialId,
|
vncCredentialId: hosts.vncCredentialId,
|
||||||
|
telnetCredentialId: hosts.telnetCredentialId,
|
||||||
authType: hosts.authType,
|
authType: hosts.authType,
|
||||||
})
|
})
|
||||||
.from(hosts)
|
.from(hosts)
|
||||||
@@ -1115,12 +1227,20 @@ router.put(
|
|||||||
sshDataObj.vncCredentialId !== undefined
|
sshDataObj.vncCredentialId !== undefined
|
||||||
? sshDataObj.vncCredentialId
|
? sshDataObj.vncCredentialId
|
||||||
: hostRecord[0].vncCredentialId;
|
: hostRecord[0].vncCredentialId;
|
||||||
|
const newTelnetCredId =
|
||||||
|
sshDataObj.telnetCredentialId !== undefined
|
||||||
|
? sshDataObj.telnetCredentialId
|
||||||
|
: hostRecord[0].telnetCredentialId;
|
||||||
const hadCredential =
|
const hadCredential =
|
||||||
hostRecord[0].credentialId !== null ||
|
hostRecord[0].credentialId !== null ||
|
||||||
hostRecord[0].rdpCredentialId !== null ||
|
hostRecord[0].rdpCredentialId !== null ||
|
||||||
hostRecord[0].vncCredentialId !== null;
|
hostRecord[0].vncCredentialId !== null ||
|
||||||
|
hostRecord[0].telnetCredentialId !== null;
|
||||||
const willHaveCredential =
|
const willHaveCredential =
|
||||||
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
|
newCredId !== null ||
|
||||||
|
newRdpCredId !== null ||
|
||||||
|
newVncCredId !== null ||
|
||||||
|
newTelnetCredId !== null;
|
||||||
if (hadCredential && !willHaveCredential) {
|
if (hadCredential && !willHaveCredential) {
|
||||||
await db
|
await db
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
@@ -1308,6 +1428,7 @@ router.get(
|
|||||||
rdpPort: hosts.rdpPort,
|
rdpPort: hosts.rdpPort,
|
||||||
vncPort: hosts.vncPort,
|
vncPort: hosts.vncPort,
|
||||||
telnetPort: hosts.telnetPort,
|
telnetPort: hosts.telnetPort,
|
||||||
|
rdpAuthType: hosts.rdpAuthType,
|
||||||
rdpCredentialId: hosts.rdpCredentialId,
|
rdpCredentialId: hosts.rdpCredentialId,
|
||||||
rdpUser: hosts.rdpUser,
|
rdpUser: hosts.rdpUser,
|
||||||
rdpPassword: hosts.rdpPassword,
|
rdpPassword: hosts.rdpPassword,
|
||||||
@@ -1318,6 +1439,8 @@ router.get(
|
|||||||
vncCredentialId: hosts.vncCredentialId,
|
vncCredentialId: hosts.vncCredentialId,
|
||||||
vncUser: hosts.vncUser,
|
vncUser: hosts.vncUser,
|
||||||
vncPassword: hosts.vncPassword,
|
vncPassword: hosts.vncPassword,
|
||||||
|
telnetAuthType: hosts.telnetAuthType,
|
||||||
|
telnetCredentialId: hosts.telnetCredentialId,
|
||||||
telnetUser: hosts.telnetUser,
|
telnetUser: hosts.telnetUser,
|
||||||
telnetPassword: hosts.telnetPassword,
|
telnetPassword: hosts.telnetPassword,
|
||||||
|
|
||||||
@@ -1660,6 +1783,8 @@ router.get(
|
|||||||
rdpPort: resolvedHost.rdpPort || 3389,
|
rdpPort: resolvedHost.rdpPort || 3389,
|
||||||
vncPort: resolvedHost.vncPort || 5900,
|
vncPort: resolvedHost.vncPort || 5900,
|
||||||
telnetPort: resolvedHost.telnetPort || 23,
|
telnetPort: resolvedHost.telnetPort || 23,
|
||||||
|
rdpAuthType: resolvedHost.rdpAuthType || null,
|
||||||
|
rdpCredentialId: resolvedHost.rdpCredentialId || null,
|
||||||
rdpUser: resolvedHost.rdpUser || null,
|
rdpUser: resolvedHost.rdpUser || null,
|
||||||
rdpPassword: resolvedHost.rdpPassword || null,
|
rdpPassword: resolvedHost.rdpPassword || null,
|
||||||
rdpDomain: resolvedHost.rdpDomain || null,
|
rdpDomain: resolvedHost.rdpDomain || null,
|
||||||
@@ -1669,6 +1794,8 @@ router.get(
|
|||||||
vncCredentialId: resolvedHost.vncCredentialId || null,
|
vncCredentialId: resolvedHost.vncCredentialId || null,
|
||||||
vncUser: resolvedHost.vncUser || null,
|
vncUser: resolvedHost.vncUser || null,
|
||||||
vncPassword: resolvedHost.vncPassword || null,
|
vncPassword: resolvedHost.vncPassword || null,
|
||||||
|
telnetAuthType: resolvedHost.telnetAuthType || null,
|
||||||
|
telnetCredentialId: resolvedHost.telnetCredentialId || null,
|
||||||
telnetUser: resolvedHost.telnetUser || null,
|
telnetUser: resolvedHost.telnetUser || null,
|
||||||
telnetPassword: resolvedHost.telnetPassword || null,
|
telnetPassword: resolvedHost.telnetPassword || null,
|
||||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||||
@@ -2445,7 +2572,7 @@ async function resolveHostCredentials(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
const resolvedHost: Record<string, unknown> = {
|
const resolvedHost: Record<string, unknown> = {
|
||||||
...host,
|
...host,
|
||||||
password: credential.password,
|
password: pickResolvedPassword(host.password, credential.password),
|
||||||
key: credential.key,
|
key: credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
|
|||||||
@@ -362,7 +362,9 @@ router.delete(
|
|||||||
return res.status(403).json({ error: "Not host owner" });
|
return res.status(403).json({ error: "Not host owner" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
|
await db
|
||||||
|
.delete(hostAccess)
|
||||||
|
.where(and(eq(hostAccess.id, accessId), eq(hostAccess.hostId, hostId)));
|
||||||
databaseLogger.info("Permission revoked", {
|
databaseLogger.info("Permission revoked", {
|
||||||
operation: "rbac_permission_revoke",
|
operation: "rbac_permission_revoke",
|
||||||
adminId: userId,
|
adminId: userId,
|
||||||
@@ -1369,7 +1371,14 @@ router.delete(
|
|||||||
return res.status(403).json({ error: "Not snippet owner" });
|
return res.status(403).json({ error: "Not snippet owner" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId));
|
await db
|
||||||
|
.delete(snippetAccess)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(snippetAccess.id, accessId),
|
||||||
|
eq(snippetAccess.snippetId, snippetId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ success: true, message: "Snippet access revoked" });
|
res.json({ success: true, message: "Snippet access revoked" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,24 +2,58 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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 { 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 { apiLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
|
||||||
|
|
||||||
// Delete session log files and DB rows older than this many days
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const LOG_RETENTION_DAYS = 30;
|
|
||||||
|
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> {
|
async function pruneOldLogs(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const cutoff = new Date(
|
const cutoff = new Date(
|
||||||
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
|
||||||
).toISOString();
|
).toISOString();
|
||||||
|
|
||||||
const old = await db
|
const old = await db
|
||||||
@@ -33,8 +67,7 @@ async function pruneOldLogs(): Promise<void> {
|
|||||||
for (const row of old) {
|
for (const row of old) {
|
||||||
if (row.recordingPath) {
|
if (row.recordingPath) {
|
||||||
const resolved = path.resolve(row.recordingPath);
|
const resolved = path.resolve(row.recordingPath);
|
||||||
const allowed = path.resolve(DATA_DIR, "session_logs");
|
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
|
||||||
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
|
|
||||||
await fs.promises.unlink(resolved).catch(() => {});
|
await fs.promises.unlink(resolved).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +114,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
|||||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
|
const isAdmin = await permissionManager.isAdmin(userId);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: sessionRecordings.id,
|
id: sessionRecordings.id,
|
||||||
@@ -90,12 +124,16 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
endedAt: sessionRecordings.endedAt,
|
endedAt: sessionRecordings.endedAt,
|
||||||
duration: sessionRecordings.duration,
|
duration: sessionRecordings.duration,
|
||||||
recordingPath: sessionRecordings.recordingPath,
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
protocol: sessionRecordings.protocol,
|
||||||
|
format: sessionRecordings.format,
|
||||||
|
username: users.username,
|
||||||
hostName: hosts.name,
|
hostName: hosts.name,
|
||||||
hostIp: hosts.ip,
|
hostIp: hosts.ip,
|
||||||
})
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
|
.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));
|
.orderBy(desc(sessionRecordings.startedAt));
|
||||||
|
|
||||||
const records = rows.map((row) => {
|
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
|
* @openapi
|
||||||
* /session_logs/{id}:
|
* /session_logs/{id}:
|
||||||
@@ -153,12 +231,13 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
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] });
|
res.json({ log: rows[0] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
apiLogger.error("Failed to fetch session log", error, {
|
apiLogger.error("Failed to fetch session log", error, {
|
||||||
@@ -206,26 +285,27 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
.select({
|
||||||
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
userId: sessionRecordings.userId,
|
||||||
|
format: sessionRecordings.format,
|
||||||
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(
|
|
||||||
eq(sessionRecordings.id, id),
|
|
||||||
eq(sessionRecordings.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0)
|
if (rows.length === 0)
|
||||||
return res.status(404).json({ error: "Not found" });
|
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;
|
const filePath = rows[0].recordingPath;
|
||||||
if (!filePath)
|
if (!filePath)
|
||||||
return res.status(404).json({ error: "No recording file" });
|
return res.status(404).json({ error: "No recording file" });
|
||||||
|
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
if (!isAllowedRecordingPath(resolvedPath)) {
|
||||||
if (!resolvedPath.startsWith(allowedBase)) {
|
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,8 +313,14 @@ router.get(
|
|||||||
return res.status(404).json({ error: "File not found" });
|
return res.status(404).json({ error: "File not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await fs.promises.readFile(resolvedPath, "utf-8");
|
const content = await fs.promises.readFile(resolvedPath);
|
||||||
res.type("text/plain").send(content);
|
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) {
|
} catch (error) {
|
||||||
apiLogger.error("Failed to read session log content", error, {
|
apiLogger.error("Failed to read session log content", error, {
|
||||||
operation: "session_log_content",
|
operation: "session_log_content",
|
||||||
@@ -277,27 +363,26 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ recordingPath: sessionRecordings.recordingPath })
|
.select({
|
||||||
|
recordingPath: sessionRecordings.recordingPath,
|
||||||
|
userId: sessionRecordings.userId,
|
||||||
|
})
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(
|
.where(eq(sessionRecordings.id, id))
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
)
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
|
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;
|
const filePath = rows[0].recordingPath;
|
||||||
|
|
||||||
await db
|
await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
|
||||||
.delete(sessionRecordings)
|
|
||||||
.where(
|
|
||||||
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filePath) {
|
if (filePath) {
|
||||||
const resolvedPath = path.resolve(filePath);
|
const resolvedPath = path.resolve(filePath);
|
||||||
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
|
||||||
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
|
|
||||||
await fs.promises.unlink(resolvedPath).catch(() => {});
|
await fs.promises.unlink(resolvedPath).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,15 @@ vi.mock("../../utils/logger.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
|
const {
|
||||||
await import("./user-oidc-utils.js");
|
isOIDCUserAllowed,
|
||||||
|
getOIDCConfigFromEnv,
|
||||||
|
extractOidcGroups,
|
||||||
|
validateLogoutTokenClaims,
|
||||||
|
} = await import("./user-oidc-utils.js");
|
||||||
|
|
||||||
|
const BACKCHANNEL_LOGOUT_EVENT =
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout";
|
||||||
|
|
||||||
describe("isOIDCUserAllowed", () => {
|
describe("isOIDCUserAllowed", () => {
|
||||||
it("allows everyone when the allow-list is empty", () => {
|
it("allows everyone when the allow-list is empty", () => {
|
||||||
@@ -199,3 +206,48 @@ describe("extractOidcGroups", () => {
|
|||||||
expect(extractOidcGroups({})).toEqual([]);
|
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 { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { Agent } from "undici";
|
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 = {
|
export type OIDCConfig = {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
client_secret: string;
|
client_secret: string;
|
||||||
@@ -417,3 +424,102 @@ export async function loadProviderConfig(
|
|||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ describe("verifyTotpReauth", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts the correct password", async () => {
|
it("does not accept the account password as a second factor", async () => {
|
||||||
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true);
|
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a valid TOTP code without a password", async () => {
|
it("accepts a valid TOTP code without a password", async () => {
|
||||||
|
|||||||
@@ -31,16 +31,6 @@ export async function verifyTotpReauth(
|
|||||||
credential: string,
|
credential: string,
|
||||||
userDataKey?: Buffer | null,
|
userDataKey?: Buffer | null,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!userRecord.isOidc && userRecord.passwordHash) {
|
|
||||||
const passwordMatch = await bcrypt.compare(
|
|
||||||
credential,
|
|
||||||
userRecord.passwordHash,
|
|
||||||
);
|
|
||||||
if (passwordMatch) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.totpSecret) {
|
if (userRecord.totpSecret) {
|
||||||
const totpSecret = userDataKey
|
const totpSecret = userDataKey
|
||||||
? LazyFieldEncryption.safeGetFieldValue(
|
? LazyFieldEncryption.safeGetFieldValue(
|
||||||
@@ -342,14 +332,6 @@ export function registerUserTotpRoutes(
|
|||||||
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { password, totp_code } = req.body;
|
const { password, totp_code } = req.body;
|
||||||
const credential = password || totp_code;
|
|
||||||
|
|
||||||
if (!credential) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "A TOTP code or password is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
@@ -358,6 +340,22 @@ export function registerUserTotpRoutes(
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
|
if (!totp_code || (!userRecord.isOidc && !password)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: userRecord.isOidc
|
||||||
|
? "A TOTP code is required"
|
||||||
|
: "Both password and TOTP code are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userRecord.isOidc &&
|
||||||
|
(!userRecord.passwordHash ||
|
||||||
|
!(await bcrypt.compare(password, userRecord.passwordHash)))
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
if (!userRecord.totpEnabled) {
|
if (!userRecord.totpEnabled) {
|
||||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||||
}
|
}
|
||||||
@@ -365,7 +363,7 @@ export function registerUserTotpRoutes(
|
|||||||
const userDataKey = authManager.getUserDataKey(userId);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
const verified = await verifyTotpReauth(
|
const verified = await verifyTotpReauth(
|
||||||
userRecord,
|
userRecord,
|
||||||
credential,
|
totp_code,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
@@ -428,14 +426,6 @@ export function registerUserTotpRoutes(
|
|||||||
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { password, totp_code } = req.body;
|
const { password, totp_code } = req.body;
|
||||||
const credential = password || totp_code;
|
|
||||||
|
|
||||||
if (!credential) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "A TOTP code or password is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
@@ -444,6 +434,22 @@ export function registerUserTotpRoutes(
|
|||||||
|
|
||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
|
if (!totp_code || (!userRecord.isOidc && !password)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: userRecord.isOidc
|
||||||
|
? "A TOTP code is required"
|
||||||
|
: "Both password and TOTP code are required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userRecord.isOidc &&
|
||||||
|
(!userRecord.passwordHash ||
|
||||||
|
!(await bcrypt.compare(password, userRecord.passwordHash)))
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
if (!userRecord.totpEnabled) {
|
if (!userRecord.totpEnabled) {
|
||||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||||
}
|
}
|
||||||
@@ -451,7 +457,7 @@ export function registerUserTotpRoutes(
|
|||||||
const userDataKey = authManager.getUserDataKey(userId);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
const verified = await verifyTotpReauth(
|
const verified = await verifyTotpReauth(
|
||||||
userRecord,
|
userRecord,
|
||||||
credential,
|
totp_code,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
getRequestBasePath,
|
getRequestBasePath,
|
||||||
getRequestBaseUrlWithForceHTTPS,
|
getRequestBaseUrlWithForceHTTPS,
|
||||||
} from "../../utils/request-origin.js";
|
} from "../../utils/request-origin.js";
|
||||||
|
import {
|
||||||
|
getDesktopOidcCallbackUrl,
|
||||||
|
isOidcTokenCallback,
|
||||||
|
} from "../../utils/oidc-desktop-callback.js";
|
||||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||||
import {
|
import {
|
||||||
getOIDCConfigFromEnv,
|
getOIDCConfigFromEnv,
|
||||||
@@ -26,6 +30,8 @@ import {
|
|||||||
extractOidcGroups,
|
extractOidcGroups,
|
||||||
loadProviderConfig,
|
loadProviderConfig,
|
||||||
buildFetchOptions,
|
buildFetchOptions,
|
||||||
|
resolveProviderByIssuer,
|
||||||
|
validateLogoutToken,
|
||||||
} from "./user-oidc-utils.js";
|
} from "./user-oidc-utils.js";
|
||||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||||
@@ -652,7 +658,10 @@ router.get("/oidc/authorize", async (req, res) => {
|
|||||||
const referer = req.get("Referer");
|
const referer = req.get("Referer");
|
||||||
let frontendOrigin;
|
let frontendOrigin;
|
||||||
if (desktopCallbackPort) {
|
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) {
|
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||||
let callbackUrl: URL;
|
let callbackUrl: URL;
|
||||||
try {
|
try {
|
||||||
@@ -999,9 +1008,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
});
|
});
|
||||||
const ghRedirectUrl = new URL(frontendOrigin);
|
const ghRedirectUrl = new URL(frontendOrigin);
|
||||||
ghRedirectUrl.searchParams.set("success", "true");
|
ghRedirectUrl.searchParams.set("success", "true");
|
||||||
const ghIsTokenCallback =
|
const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin);
|
||||||
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
|
||||||
frontendOrigin.startsWith("termix-mobile:");
|
|
||||||
const ghMaxAge =
|
const ghMaxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
@@ -1108,25 +1115,20 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (tokenData.id_token) {
|
if (tokenData.id_token) {
|
||||||
try {
|
userInfo = await verifyOIDCToken(
|
||||||
userInfo = await verifyOIDCToken(
|
tokenData.id_token as string,
|
||||||
tokenData.id_token as string,
|
config.issuer_url,
|
||||||
config.issuer_url,
|
config.client_id,
|
||||||
config.client_id,
|
caCert,
|
||||||
caCert,
|
);
|
||||||
);
|
|
||||||
} catch {
|
const expectedNonce = (storedNonce as { value: string }).value;
|
||||||
try {
|
if (userInfo.nonce !== expectedNonce) {
|
||||||
const parts = (tokenData.id_token as string).split(".");
|
authLogger.warn("OIDC ID token nonce mismatch", {
|
||||||
if (parts.length === 3) {
|
operation: "oidc_nonce_mismatch",
|
||||||
const payload = JSON.parse(
|
providerId: callbackProviderId,
|
||||||
Buffer.from(parts[1], "base64").toString(),
|
});
|
||||||
);
|
return res.status(401).json({ error: "Invalid OIDC token nonce" });
|
||||||
userInfo = payload;
|
|
||||||
}
|
|
||||||
} catch (decodeError) {
|
|
||||||
authLogger.error("Failed to decode ID token payload:", decodeError);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,10 +1466,16 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
"oidc_role_shared_credentials",
|
"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, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
rememberMe: storedRememberMe,
|
rememberMe: storedRememberMe,
|
||||||
|
oidcSub,
|
||||||
|
oidcSid,
|
||||||
|
ssoProviderId: callbackProviderDbId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
authLogger.success("OIDC login successful", {
|
authLogger.success("OIDC login successful", {
|
||||||
@@ -1479,9 +1487,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
const redirectUrl = new URL(frontendOrigin);
|
const redirectUrl = new URL(frontendOrigin);
|
||||||
redirectUrl.searchParams.set("success", "true");
|
redirectUrl.searchParams.set("success", "true");
|
||||||
|
|
||||||
const isTokenCallback =
|
const isTokenCallback = isOidcTokenCallback(frontendOrigin);
|
||||||
frontendOrigin.startsWith("http://127.0.0.1:") ||
|
|
||||||
frontendOrigin.startsWith("termix-mobile:");
|
|
||||||
|
|
||||||
const maxAge =
|
const maxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
@@ -1788,6 +1794,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
|
* @openapi
|
||||||
* /users/me:
|
* /users/me:
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { guacLogger } from "../utils/logger.js";
|
|||||||
import { GuacamoleTokenService } from "./token-service.js";
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { resolveGuacdOptions } from "../utils/guacd-config.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();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
|
||||||
@@ -21,6 +25,63 @@ function readGuacdOptions(): { host: string; port: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GUAC_WS_PORT = 30008;
|
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 = {
|
const websocketOptions = {
|
||||||
port: GUAC_WS_PORT,
|
port: GUAC_WS_PORT,
|
||||||
@@ -89,35 +150,31 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
clientOptions,
|
clientOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
server.on(
|
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
||||||
"open",
|
guacLogger.info("Guacamole connection opened", {
|
||||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
operation: "guac_connection_open",
|
||||||
guacLogger.info("Guacamole connection opened", {
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
operation: "guac_connection_open",
|
});
|
||||||
type: clientConnection.connectionSettings?.type,
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.on(
|
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||||
"close",
|
guacLogger.info("Guacamole connection closed", {
|
||||||
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
operation: "guac_connection_close",
|
||||||
guacLogger.info("Guacamole connection closed", {
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
operation: "guac_connection_close",
|
});
|
||||||
type: clientConnection.connectionSettings?.type,
|
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||||
|
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||||
|
operation: "guac_recording_persist_error",
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
server.on(
|
server.on(
|
||||||
"error",
|
"error",
|
||||||
(
|
(clientConnection: GuacamoleClientConnection, error: Error) => {
|
||||||
clientConnection: { connectionSettings?: Record<string, unknown> },
|
|
||||||
error: Error,
|
|
||||||
) => {
|
|
||||||
guacLogger.error("Guacamole connection error", error, {
|
guacLogger.error("Guacamole connection error", error, {
|
||||||
operation: "guac_connection_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 { eq, and } from "drizzle-orm";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import path from "path";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../utils/guacd-config.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||||
|
|
||||||
router.use(authManager.createAuthMiddleware());
|
router.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
@@ -526,6 +529,28 @@ router.post(
|
|||||||
? { guacdPort: perConnectionGuacdPort }
|
? { 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) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
@@ -533,22 +558,28 @@ router.post(
|
|||||||
guacConfig["drive-path"] = "/drive";
|
guacConfig["drive-path"] = "/drive";
|
||||||
guacConfig["create-drive-path"] = true;
|
guacConfig["create-drive-path"] = true;
|
||||||
}
|
}
|
||||||
token = tokenService.createRdpToken(hostname, username, password, {
|
token = tokenService.createRdpToken(
|
||||||
port,
|
hostname,
|
||||||
domain,
|
username,
|
||||||
security:
|
password,
|
||||||
(host.rdpSecurity as string) ||
|
{
|
||||||
(host.security as string) ||
|
port,
|
||||||
undefined,
|
domain,
|
||||||
"ignore-cert":
|
security:
|
||||||
host.rdpIgnoreCert !== undefined
|
(host.rdpSecurity as string) ||
|
||||||
? !!host.rdpIgnoreCert
|
(host.security as string) ||
|
||||||
: host.ignoreCert !== undefined
|
undefined,
|
||||||
? !!host.ignoreCert
|
"ignore-cert":
|
||||||
: true,
|
host.rdpIgnoreCert !== undefined
|
||||||
...guacConfig,
|
? !!host.rdpIgnoreCert
|
||||||
...guacdOverrides,
|
: host.ignoreCert !== undefined
|
||||||
});
|
? !!host.ignoreCert
|
||||||
|
: true,
|
||||||
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
|
},
|
||||||
|
recordingMetadata,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
token = tokenService.createVncToken(
|
token = tokenService.createVncToken(
|
||||||
@@ -561,14 +592,21 @@ router.post(
|
|||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
|
recordingMetadata,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
token = tokenService.createTelnetToken(hostname, username, password, {
|
token = tokenService.createTelnetToken(
|
||||||
port,
|
hostname,
|
||||||
...guacConfig,
|
username,
|
||||||
...guacdOverrides,
|
password,
|
||||||
});
|
{
|
||||||
|
port,
|
||||||
|
...guacConfig,
|
||||||
|
...guacdOverrides,
|
||||||
|
},
|
||||||
|
recordingMetadata,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({ error: "Invalid connection type" });
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
|
|||||||
@@ -45,4 +45,23 @@ describe("GuacamoleTokenService", () => {
|
|||||||
});
|
});
|
||||||
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
|
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 {
|
export interface GuacamoleToken {
|
||||||
connection: GuacamoleConnectionSettings;
|
connection: GuacamoleConnectionSettings;
|
||||||
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleRecordingMetadata {
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
protocol: "rdp" | "vnc" | "telnet";
|
||||||
|
path: string;
|
||||||
|
startedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CIPHER = "aes-256-cbc";
|
const CIPHER = "aes-256-cbc";
|
||||||
@@ -127,6 +136,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -144,6 +154,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -156,6 +167,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -171,6 +183,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -183,6 +196,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
|
recording?: GuacamoleRecordingMetadata,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -198,6 +212,7 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recording,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
pickResolvedUsername,
|
pickResolvedUsername,
|
||||||
|
pickResolvedPassword,
|
||||||
expandOidcUsername,
|
expandOidcUsername,
|
||||||
} from "./credential-username.js";
|
} 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", () => {
|
describe("expandOidcUsername", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ export function pickResolvedUsername(
|
|||||||
return cred;
|
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
|
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getContainerRuntimeConfig,
|
getContainerRuntimeConfig,
|
||||||
type ContainerRuntime,
|
type ContainerRuntime,
|
||||||
} from "./container-runtime.js";
|
} from "./container-runtime.js";
|
||||||
|
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||||
|
|
||||||
const sshLogger = systemLogger;
|
const sshLogger = systemLogger;
|
||||||
|
|
||||||
@@ -276,6 +277,10 @@ async function createJumpHostChain(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.sock) {
|
||||||
|
await resolveSshConnectConfigHost(config);
|
||||||
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
client.on("ready", () => resolve());
|
client.on("ready", () => resolve());
|
||||||
client.on("error", reject);
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
client.on("ready", () => resolve());
|
client.on("ready", () => resolve());
|
||||||
client.on("error", reject);
|
client.on("error", reject);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const sshLogger = logger;
|
|||||||
|
|
||||||
type DockerSession = {
|
type DockerSession = {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
|
userId?: string;
|
||||||
lastActive: number;
|
lastActive: number;
|
||||||
activeOperations: number;
|
activeOperations: number;
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
@@ -46,6 +47,10 @@ export function registerDockerContainerRoutes(
|
|||||||
): void {
|
): void {
|
||||||
const getRuntime = (session: DockerSession) =>
|
const getRuntime = (session: DockerSession) =>
|
||||||
session.containerRuntime ?? "docker";
|
session.containerRuntime ?? "docker";
|
||||||
|
const getOwnedSession = (sessionId: string, userId: string) => {
|
||||||
|
const session = sshSessions[sessionId];
|
||||||
|
return session?.userId === userId ? session : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -89,7 +94,7 @@ export function registerDockerContainerRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -189,7 +194,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -287,7 +292,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -387,7 +392,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -487,7 +492,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -587,7 +592,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -687,7 +692,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -792,7 +797,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -925,7 +930,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -1040,7 +1045,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = sshSessions[sessionId];
|
const session = getOwnedSession(sessionId, userId);
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getRuntimeLabel,
|
getRuntimeLabel,
|
||||||
type ContainerRuntime,
|
type ContainerRuntime,
|
||||||
} from "./container-runtime.js";
|
} from "./container-runtime.js";
|
||||||
|
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||||
|
|
||||||
const sshLogger = logger;
|
const sshLogger = logger;
|
||||||
|
|
||||||
@@ -1617,6 +1618,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
await resolveSshConnectConfigHost(config);
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2136,12 +2138,19 @@ app.post("/docker/ssh/keepalive", async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
app.get("/docker/ssh/status", async (req, res) => {
|
app.get("/docker/ssh/status", async (req, res) => {
|
||||||
const sessionId = req.query.sessionId as string;
|
const sessionId = req.query.sessionId as string;
|
||||||
|
const userId = getRequestUserId(req);
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return res.status(400).json({ error: "Session ID is required" });
|
return res.status(400).json({ error: "Session ID is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sshSessions[sessionId];
|
||||||
|
const isConnected =
|
||||||
|
session?.userId === userId && session.isConnected === true;
|
||||||
|
|
||||||
res.json({ success: true, connected: isConnected });
|
res.json({ success: true, connected: isConnected });
|
||||||
});
|
});
|
||||||
@@ -2191,6 +2200,10 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.userId !== userId) {
|
||||||
|
return res.status(403).json({ error: "Session access denied" });
|
||||||
|
}
|
||||||
|
|
||||||
session.lastActive = Date.now();
|
session.lastActive = Date.now();
|
||||||
session.activeOperations++;
|
session.activeOperations++;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
execWithSudo,
|
||||||
type SSHSession,
|
type SSHSession,
|
||||||
} from "./file-manager-session.js";
|
} from "./file-manager-session.js";
|
||||||
import { isWindowsSftpPath } from "./transfer-paths.js";
|
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
|
||||||
|
|
||||||
type FileOperationRoutesDeps = {
|
type FileOperationRoutesDeps = {
|
||||||
sshSessions: Record<string, SSHSession>;
|
sshSessions: Record<string, SSHSession>;
|
||||||
@@ -375,22 +375,10 @@ export function registerFileOperationRoutes(
|
|||||||
});
|
});
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
const isWindowsPath = isWindowsSftpPath(itemPath);
|
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
||||||
let deleteCommand: string;
|
itemPath,
|
||||||
if (isWindowsPath) {
|
Boolean(isDirectory),
|
||||||
const winPath = itemPath
|
);
|
||||||
.replace(/\//g, "\\")
|
|
||||||
.replace(/^\\([A-Za-z]:\\)/, "$1");
|
|
||||||
const escapedWinPath = winPath.replace(/"/g, '""');
|
|
||||||
deleteCommand = isDirectory
|
|
||||||
? `rd /s /q "${escapedWinPath}"`
|
|
||||||
: `del /f /q "${escapedWinPath}"`;
|
|
||||||
} else {
|
|
||||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
|
||||||
deleteCommand = isDirectory
|
|
||||||
? `rm -rf '${escapedPath}'`
|
|
||||||
: `rm -f '${escapedPath}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -421,89 +409,85 @@ export function registerFileOperationRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
execChannel(
|
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
||||||
sshConn,
|
if (err) {
|
||||||
`${deleteCommand} && echo "SUCCESS"`,
|
fileLogger.error("SSH deleteItem error:", err);
|
||||||
(err, stream) => {
|
res.status(500).json({ error: err.message });
|
||||||
if (err) {
|
resolve();
|
||||||
fileLogger.error("SSH deleteItem error:", err);
|
return;
|
||||||
res.status(500).json({ error: err.message });
|
}
|
||||||
|
|
||||||
|
let outputData = "";
|
||||||
|
let errorData = "";
|
||||||
|
let permissionDenied = false;
|
||||||
|
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
outputData += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
errorData += chunk.toString();
|
||||||
|
if (chunk.toString().includes("Permission denied")) {
|
||||||
|
permissionDenied = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on("close", (code) => {
|
||||||
|
if (permissionDenied) {
|
||||||
|
if (sshConn.sudoPassword) {
|
||||||
|
executeDelete(true).then(resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||||
|
res.status(403).json({
|
||||||
|
error: `Permission denied: Cannot delete ${itemPath}.`,
|
||||||
|
needsSudo: true,
|
||||||
|
});
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputData = "";
|
if (outputData.includes("SUCCESS")) {
|
||||||
let errorData = "";
|
fileLogger.success("Item deleted successfully", {
|
||||||
let permissionDenied = false;
|
operation: "file_delete_success",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: itemPath,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
message: "Item deleted successfully",
|
||||||
|
path: itemPath,
|
||||||
|
toast: {
|
||||||
|
type: "success",
|
||||||
|
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const detail =
|
||||||
|
errorData.trim() ||
|
||||||
|
outputData.trim() ||
|
||||||
|
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
||||||
|
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
|
||||||
|
operation: "file_delete_failed",
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
path: itemPath,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: `Delete failed: ${detail}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
stream.on("data", (chunk: Buffer) => {
|
stream.on("error", (streamErr) => {
|
||||||
outputData += chunk.toString();
|
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
||||||
});
|
res
|
||||||
|
.status(500)
|
||||||
stream.stderr.on("data", (chunk: Buffer) => {
|
.json({ error: `Stream error: ${streamErr.message}` });
|
||||||
errorData += chunk.toString();
|
resolve();
|
||||||
if (chunk.toString().includes("Permission denied")) {
|
});
|
||||||
permissionDenied = true;
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
|
||||||
if (permissionDenied) {
|
|
||||||
if (sshConn.sudoPassword) {
|
|
||||||
executeDelete(true).then(resolve);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
|
||||||
res.status(403).json({
|
|
||||||
error: `Permission denied: Cannot delete ${itemPath}.`,
|
|
||||||
needsSudo: true,
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputData.includes("SUCCESS")) {
|
|
||||||
fileLogger.success("Item deleted successfully", {
|
|
||||||
operation: "file_delete_success",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
path: itemPath,
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
message: "Item deleted successfully",
|
|
||||||
path: itemPath,
|
|
||||||
toast: {
|
|
||||||
type: "success",
|
|
||||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const detail =
|
|
||||||
errorData.trim() ||
|
|
||||||
outputData.trim() ||
|
|
||||||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
|
|
||||||
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
|
|
||||||
operation: "file_delete_failed",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
path: itemPath,
|
|
||||||
});
|
|
||||||
res.status(500).json({
|
|
||||||
error: `Delete failed: ${detail}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (streamErr) => {
|
|
||||||
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json({ error: `Stream error: ${streamErr.message}` });
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from "./file-manager-session.js";
|
} from "./file-manager-session.js";
|
||||||
import { registerFileListingRoutes } from "./file-manager-list-routes.js";
|
import { registerFileListingRoutes } from "./file-manager-list-routes.js";
|
||||||
import { registerFileOperationRoutes } from "./file-manager-operation-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 { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
|
||||||
import { registerFileActionRoutes } from "./file-manager-action-routes.js";
|
import { registerFileActionRoutes } from "./file-manager-action-routes.js";
|
||||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||||
@@ -194,6 +195,7 @@ async function buildDedicatedTransferConnectConfig(
|
|||||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
await resolveSshConnectConfigHost(config);
|
||||||
|
|
||||||
const authType = host.authType;
|
const authType = host.authType;
|
||||||
|
|
||||||
@@ -1759,6 +1761,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
await resolveSshConnectConfigHost(config);
|
||||||
client.connect(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 {
|
import {
|
||||||
supportsMetrics,
|
supportsMetrics,
|
||||||
isTcpPingEnabled,
|
isTcpPingEnabled,
|
||||||
createConnectionLog,
|
createConnectionLog,
|
||||||
|
tcpPingThroughJumpHost,
|
||||||
} from "./host-metrics-helpers.js";
|
} from "./host-metrics-helpers.js";
|
||||||
|
|
||||||
describe("supportsMetrics", () => {
|
describe("supportsMetrics", () => {
|
||||||
@@ -66,3 +68,57 @@ describe("createConnectionLog", () => {
|
|||||||
expect("timestamp" in entry).toBe(false);
|
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 { LogEntry, ConnectionStage } from "../../types/connection-log.js";
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
|
||||||
export type StatsCapableHost = {
|
export type StatsCapableHost = {
|
||||||
connectionType?: string;
|
connectionType?: string;
|
||||||
@@ -21,6 +22,32 @@ export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
|||||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
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(
|
export function createConnectionLog(
|
||||||
type: "info" | "success" | "warning" | "error",
|
type: "info" | "success" | "warning" | "error",
|
||||||
stage: ConnectionStage,
|
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 requestQueue = new RequestQueue();
|
||||||
export const metricsCache = new MetricsCache();
|
export const metricsCache = new MetricsCache();
|
||||||
export const authFailureTracker = new AuthFailureTracker();
|
export const authFailureTracker = new AuthFailureTracker();
|
||||||
|
|||||||
+198
-33
@@ -4,7 +4,10 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { Client, type ConnectConfig } from "ssh2";
|
import { Client, type ConnectConfig } from "ssh2";
|
||||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
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 { getDb } from "../database/db/index.js";
|
||||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -38,6 +41,7 @@ import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences
|
|||||||
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
|
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
|
||||||
import { AlertEngine } from "./alert-engine.js";
|
import { AlertEngine } from "./alert-engine.js";
|
||||||
import { registerManagerRoutes } from "./managers/index.js";
|
import { registerManagerRoutes } from "./managers/index.js";
|
||||||
|
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||||
import { AccessDeniedError } from "./managers/route-helpers.js";
|
import { AccessDeniedError } from "./managers/route-helpers.js";
|
||||||
import type { ManagerHost } from "./managers/types.js";
|
import type { ManagerHost } from "./managers/types.js";
|
||||||
import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
|
import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
|
||||||
@@ -45,6 +49,7 @@ import {
|
|||||||
createConnectionLog,
|
createConnectionLog,
|
||||||
isTcpPingEnabled,
|
isTcpPingEnabled,
|
||||||
supportsMetrics,
|
supportsMetrics,
|
||||||
|
tcpPingThroughJumpHost,
|
||||||
} from "./host-metrics-helpers.js";
|
} from "./host-metrics-helpers.js";
|
||||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||||
import {
|
import {
|
||||||
@@ -57,9 +62,12 @@ import {
|
|||||||
} from "./host-metrics-sessions.js";
|
} from "./host-metrics-sessions.js";
|
||||||
import {
|
import {
|
||||||
authFailureTracker,
|
authFailureTracker,
|
||||||
|
hostPollCache,
|
||||||
metricsCache,
|
metricsCache,
|
||||||
|
metricsPollLimiter,
|
||||||
pollingBackoff,
|
pollingBackoff,
|
||||||
requestQueue,
|
requestQueue,
|
||||||
|
statusPollLimiter,
|
||||||
} from "./host-metrics-state.js";
|
} from "./host-metrics-state.js";
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -104,6 +112,7 @@ interface SSHHostWithCredentials {
|
|||||||
rdpPort?: number;
|
rdpPort?: number;
|
||||||
vncPort?: number;
|
vncPort?: number;
|
||||||
telnetPort?: number;
|
telnetPort?: number;
|
||||||
|
vaultProfile?: { id?: number | null } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusEntry = {
|
type StatusEntry = {
|
||||||
@@ -163,6 +172,9 @@ class PollingManager {
|
|||||||
private activeViewers = new Map<number, Set<string>>();
|
private activeViewers = new Map<number, Set<string>>();
|
||||||
private viewerDetails = new Map<string, MetricsViewer>();
|
private viewerDetails = new Map<string, MetricsViewer>();
|
||||||
private viewerCleanupInterval: NodeJS.Timeout;
|
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() {
|
constructor() {
|
||||||
this.viewerCleanupInterval = setInterval(() => {
|
this.viewerCleanupInterval = setInterval(() => {
|
||||||
@@ -170,6 +182,81 @@ class PollingManager {
|
|||||||
}, 60000);
|
}, 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(): {
|
private getGlobalDefaults(): {
|
||||||
statusCheckInterval: number;
|
statusCheckInterval: number;
|
||||||
metricsInterval: number;
|
metricsInterval: number;
|
||||||
@@ -303,14 +390,17 @@ class PollingManager {
|
|||||||
this.pollingConfigs.set(host.id, config);
|
this.pollingConfigs.set(host.id, config);
|
||||||
|
|
||||||
if (isTcpPingEnabled(statsConfig)) {
|
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(() => {
|
config.statusTimer = setInterval(() => {
|
||||||
const latestConfig = this.pollingConfigs.get(host.id);
|
const latestConfig = this.pollingConfigs.get(host.id);
|
||||||
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
|
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
|
||||||
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
@@ -318,9 +408,27 @@ class PollingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
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(() => {
|
config.metricsTimer = setInterval(() => {
|
||||||
const latestConfig = this.pollingConfigs.get(host.id);
|
const latestConfig = this.pollingConfigs.get(host.id);
|
||||||
@@ -329,15 +437,10 @@ class PollingManager {
|
|||||||
latestConfig.statsConfig.metricsEnabled &&
|
latestConfig.statsConfig.metricsEnabled &&
|
||||||
supportsMetrics(latestConfig.host)
|
supportsMetrics(latestConfig.host)
|
||||||
) {
|
) {
|
||||||
this.pollHostMetrics(
|
this.scheduleMetricsPoll(
|
||||||
latestConfig.host,
|
latestConfig.host,
|
||||||
latestConfig.viewerUserId,
|
latestConfig.viewerUserId,
|
||||||
).catch((err) => {
|
);
|
||||||
statsLogger.error("Metrics polling failed", err, {
|
|
||||||
operation: "metrics_poll_unhandled",
|
|
||||||
hostId: host.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
@@ -350,30 +453,51 @@ class PollingManager {
|
|||||||
viewerUserId?: string,
|
viewerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = viewerUserId || host.userId;
|
const userId = viewerUserId || host.userId;
|
||||||
const refreshedHost = await fetchHostById(host.id, userId);
|
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
||||||
if (!refreshedHost) {
|
if (!refreshedHost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let pingHost = refreshedHost.ip;
|
|
||||||
const ct = refreshedHost.connectionType || "ssh";
|
const ct = refreshedHost.connectionType || "ssh";
|
||||||
let pingPort: number;
|
let pingPort: number;
|
||||||
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
|
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
|
||||||
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
|
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
|
||||||
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
|
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
|
||||||
else pingPort = refreshedHost.port;
|
else pingPort = refreshedHost.port;
|
||||||
|
|
||||||
|
let isOnline: boolean;
|
||||||
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
||||||
const firstJump = await fetchHostById(
|
const proxyConfig: SOCKS5Config | null =
|
||||||
refreshedHost.jumpHosts[0].hostId,
|
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,
|
userId,
|
||||||
|
proxyConfig,
|
||||||
);
|
);
|
||||||
if (firstJump) {
|
isOnline = jumpClient
|
||||||
pingHost = firstJump.ip;
|
? await tcpPingThroughJumpHost(
|
||||||
pingPort = firstJump.port;
|
jumpClient,
|
||||||
}
|
refreshedHost.ip,
|
||||||
|
pingPort,
|
||||||
|
5000,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
} else {
|
||||||
|
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
|
||||||
}
|
}
|
||||||
const isOnline = await tcpPing(pingHost, pingPort, 5000);
|
|
||||||
const statusEntry: StatusEntry = {
|
const statusEntry: StatusEntry = {
|
||||||
status: isOnline ? "online" : "offline",
|
status: isOnline ? "online" : "offline",
|
||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
@@ -399,7 +523,7 @@ class PollingManager {
|
|||||||
viewerUserId?: string,
|
viewerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = viewerUserId || host.userId;
|
const userId = viewerUserId || host.userId;
|
||||||
const refreshedHost = await fetchHostById(host.id, userId);
|
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
||||||
if (!refreshedHost) {
|
if (!refreshedHost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -554,6 +678,9 @@ class PollingManager {
|
|||||||
this.metricsStore.delete(hostId);
|
this.metricsStore.delete(hostId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hostPollCache.invalidate(hostId);
|
||||||
|
this.statusInFlight.delete(hostId);
|
||||||
|
this.metricsInFlight.delete(hostId);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopMetricsOnly(hostId: number): void {
|
stopMetricsOnly(hostId: number): void {
|
||||||
@@ -960,9 +1087,10 @@ async function resolveHostCredentials(
|
|||||||
host.overrideCredentialUsername,
|
host.overrideCredentialUsername,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credential.password) {
|
baseHost.password = pickResolvedPassword(
|
||||||
baseHost.password = credential.password;
|
host.password,
|
||||||
}
|
credential.password,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
credential.key ||
|
credential.key ||
|
||||||
(credential as Record<string, unknown>).privateKey
|
(credential as Record<string, unknown>).privateKey
|
||||||
@@ -1128,6 +1256,8 @@ async function buildSshConfig(
|
|||||||
// no credentials needed
|
// no credentials needed
|
||||||
} else if (host.authType === "opkssh") {
|
} else if (host.authType === "opkssh") {
|
||||||
// cert auth setup happens in createSshFactory (needs client instance)
|
// cert auth setup happens in createSshFactory (needs client instance)
|
||||||
|
} else if (host.authType === "vault") {
|
||||||
|
// cert auth setup happens in createSshFactory (needs client instance)
|
||||||
} else if (host.authType === "credential") {
|
} else if (host.authType === "credential") {
|
||||||
if (host.password) {
|
if (host.password) {
|
||||||
base.password = host.password;
|
base.password = host.password;
|
||||||
@@ -1186,6 +1316,10 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
|||||||
}
|
}
|
||||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
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 =
|
const proxyConfig: SOCKS5Config | null =
|
||||||
@@ -1331,8 +1465,18 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
|||||||
client.connect(config);
|
client.connect(config);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else if (config.sock) {
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
|
} else {
|
||||||
|
resolveSshConnectConfigHost(config)
|
||||||
|
.then(() => {
|
||||||
|
client.connect(config);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1645,7 +1789,10 @@ app.get("/status", async (req, res) => {
|
|||||||
|
|
||||||
const result: Record<number, StatusEntry> = {};
|
const result: Record<number, StatusEntry> = {};
|
||||||
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
|
||||||
result[id] = entry;
|
const access = await permissionManager.canAccessHost(userId, id, "read");
|
||||||
|
if (access.hasAccess) {
|
||||||
|
result[id] = entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
@@ -1683,6 +1830,11 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const access = await permissionManager.canAccessHost(userId, id, "read");
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return res.status(404).json({ error: "Status not available" });
|
||||||
|
}
|
||||||
|
|
||||||
const statuses = pollingManager.getAllStatuses();
|
const statuses = pollingManager.getAllStatuses();
|
||||||
if (statuses.size === 0) {
|
if (statuses.size === 0) {
|
||||||
await pollingManager.initializePolling(userId);
|
await pollingManager.initializePolling(userId);
|
||||||
@@ -1710,7 +1862,7 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
|||||||
* 401:
|
* 401:
|
||||||
* description: Session expired - please log in again.
|
* description: Session expired - please log in again.
|
||||||
*/
|
*/
|
||||||
app.post("/clear-connections", async (req, res) => {
|
app.post("/clear-connections", requireAdmin, async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
@@ -1729,7 +1881,7 @@ app.post("/clear-connections", async (req, res) => {
|
|||||||
* /refresh:
|
* /refresh:
|
||||||
* post:
|
* post:
|
||||||
* summary: Refresh polling
|
* summary: Refresh polling
|
||||||
* description: Clears all SSH connections and refreshes host polling.
|
* description: Refreshes host polling for the authenticated user.
|
||||||
* tags:
|
* tags:
|
||||||
* - Host Metrics
|
* - Host Metrics
|
||||||
* responses:
|
* responses:
|
||||||
@@ -1748,8 +1900,6 @@ app.post("/refresh", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionPool.clearAllConnections();
|
|
||||||
|
|
||||||
await pollingManager.refreshHostPolling(userId);
|
await pollingManager.refreshHostPolling(userId);
|
||||||
res.json({ message: "Polling refreshed" });
|
res.json({ message: "Polling refreshed" });
|
||||||
});
|
});
|
||||||
@@ -1799,6 +1949,7 @@ app.post("/host-updated", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
hostPollCache.invalidate(hostId);
|
||||||
const host = await fetchHostById(hostId, userId);
|
const host = await fetchHostById(hostId, userId);
|
||||||
if (host) {
|
if (host) {
|
||||||
connectionPool.clearKeyConnections(getPoolKey(host));
|
connectionPool.clearKeyConnections(getPoolKey(host));
|
||||||
@@ -2070,6 +2221,10 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
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<{
|
const connectionPromise = new Promise<{
|
||||||
@@ -2357,7 +2512,17 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
client.connect(config);
|
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 { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
|
pickResolvedPassword,
|
||||||
pickResolvedUsername,
|
pickResolvedUsername,
|
||||||
expandOidcUsername,
|
expandOidcUsername,
|
||||||
} from "./credential-username.js";
|
} from "./credential-username.js";
|
||||||
@@ -19,6 +20,14 @@ export async function resolveHostById(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SSHHost | null> {
|
): Promise<SSHHost | null> {
|
||||||
|
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) return null;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const hostResults = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
@@ -190,7 +199,7 @@ export async function resolveHostById(
|
|||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
const cred = credentials[0] as Record<string, unknown>;
|
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
|
// Prefer the normalised private key; fall back to raw key field
|
||||||
host.key = (cred.privateKey || cred.key) as string | null;
|
host.key = (cred.privateKey || cred.key) as string | null;
|
||||||
host.keyPassword = cred.keyPassword;
|
host.keyPassword = cred.keyPassword;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import yaml from "js-yaml";
|
import { load as loadYaml } from "js-yaml";
|
||||||
|
|
||||||
const AUTH_TIMEOUT = 60 * 1000;
|
const AUTH_TIMEOUT = 60 * 1000;
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ async function checkOPKConfigExists(): Promise<{
|
|||||||
|
|
||||||
let providers: ProviderRedirectInfo[] = [];
|
let providers: ProviderRedirectInfo[] = [];
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.load(content) as {
|
const parsed = loadYaml(content) as {
|
||||||
providers?: Array<{
|
providers?: Array<{
|
||||||
alias?: string;
|
alias?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
|
|||||||
@@ -8,18 +8,29 @@ interface PooledConnection {
|
|||||||
hostKey: string;
|
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 {
|
class SSHConnectionPool {
|
||||||
private connections = new Map<string, PooledConnection[]>();
|
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;
|
private cleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cleanupInterval = setInterval(
|
this.cleanupInterval = setInterval(() => {
|
||||||
() => {
|
this.cleanup();
|
||||||
this.cleanup();
|
}, CLEANUP_INTERVAL_MS);
|
||||||
},
|
|
||||||
2 * 60 * 1000,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isConnectionHealthy(client: Client): boolean {
|
private isConnectionHealthy(client: Client): boolean {
|
||||||
@@ -38,6 +49,126 @@ class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeUnhealthy(
|
||||||
|
key: string,
|
||||||
|
connections: PooledConnection[],
|
||||||
|
target: PooledConnection,
|
||||||
|
): PooledConnection[] {
|
||||||
|
sshLogger.warn("Removing unhealthy connection from pool", {
|
||||||
|
operation: "pool_remove_dead",
|
||||||
|
hostKey: key,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
target.client.end();
|
||||||
|
} catch {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
const filtered = connections.filter((c) => c !== target);
|
||||||
|
this.connections.set(key, filtered);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPooledClient(
|
||||||
|
key: string,
|
||||||
|
factory: () => Promise<Client>,
|
||||||
|
existing: PooledConnection[],
|
||||||
|
): Promise<Client> {
|
||||||
|
const client = await factory();
|
||||||
|
const pooled: PooledConnection = {
|
||||||
|
client,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
inUse: true,
|
||||||
|
hostKey: key,
|
||||||
|
};
|
||||||
|
existing.push(pooled);
|
||||||
|
this.connections.set(key, existing);
|
||||||
|
|
||||||
|
client.on("end", () => {
|
||||||
|
this.removeConnection(key, client);
|
||||||
|
});
|
||||||
|
client.on("close", () => {
|
||||||
|
this.removeConnection(key, client);
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
maxWaitMs: this.maxWaitMs,
|
||||||
|
});
|
||||||
|
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(
|
async getConnection(
|
||||||
key: string,
|
key: string,
|
||||||
factory: () => Promise<Client>,
|
factory: () => Promise<Client>,
|
||||||
@@ -47,17 +178,7 @@ class SSHConnectionPool {
|
|||||||
const available = connections.find((conn) => !conn.inUse);
|
const available = connections.find((conn) => !conn.inUse);
|
||||||
if (available) {
|
if (available) {
|
||||||
if (!this.isConnectionHealthy(available.client)) {
|
if (!this.isConnectionHealthy(available.client)) {
|
||||||
sshLogger.warn("Removing unhealthy connection from pool", {
|
connections = this.removeUnhealthy(key, connections, available);
|
||||||
operation: "pool_remove_dead",
|
|
||||||
hostKey: key,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
available.client.end();
|
|
||||||
} catch {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
connections = connections.filter((c) => c !== available);
|
|
||||||
this.connections.set(key, connections);
|
|
||||||
} else {
|
} else {
|
||||||
available.inUse = true;
|
available.inUse = true;
|
||||||
available.lastUsed = Date.now();
|
available.lastUsed = Date.now();
|
||||||
@@ -66,63 +187,10 @@ class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connections.length < this.maxConnectionsPerHost) {
|
if (connections.length < this.maxConnectionsPerHost) {
|
||||||
const client = await factory();
|
return this.createPooledClient(key, factory, connections);
|
||||||
const pooled: PooledConnection = {
|
|
||||||
client,
|
|
||||||
lastUsed: Date.now(),
|
|
||||||
inUse: true,
|
|
||||||
hostKey: key,
|
|
||||||
};
|
|
||||||
connections.push(pooled);
|
|
||||||
this.connections.set(key, connections);
|
|
||||||
|
|
||||||
client.on("end", () => {
|
|
||||||
this.removeConnection(key, client);
|
|
||||||
});
|
|
||||||
client.on("close", () => {
|
|
||||||
this.removeConnection(key, client);
|
|
||||||
});
|
|
||||||
|
|
||||||
return client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return this.enqueueWaiter(key, factory);
|
||||||
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
|
|
||||||
}
|
|
||||||
const filtered = conns.filter((c) => c !== avail);
|
|
||||||
this.connections.set(key, filtered);
|
|
||||||
factory().then((client) => {
|
|
||||||
const pooled: PooledConnection = {
|
|
||||||
client,
|
|
||||||
lastUsed: Date.now(),
|
|
||||||
inUse: true,
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
avail.inUse = true;
|
|
||||||
avail.lastUsed = Date.now();
|
|
||||||
resolve(avail.client);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTimeout(checkAvailable, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkAvailable();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseConnection(key: string, client: Client): void {
|
releaseConnection(key: string, client: Client): void {
|
||||||
@@ -131,6 +199,7 @@ class SSHConnectionPool {
|
|||||||
if (pooled) {
|
if (pooled) {
|
||||||
pooled.inUse = false;
|
pooled.inUse = false;
|
||||||
pooled.lastUsed = Date.now();
|
pooled.lastUsed = Date.now();
|
||||||
|
this.wakeWaiter(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +212,8 @@ class SSHConnectionPool {
|
|||||||
} else {
|
} else {
|
||||||
this.connections.set(key, filtered);
|
this.connections.set(key, filtered);
|
||||||
}
|
}
|
||||||
|
// A slot or idle connection may now be free for waiters.
|
||||||
|
this.wakeWaiter(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearKeyConnections(key: string): void {
|
clearKeyConnections(key: string): void {
|
||||||
@@ -155,15 +226,15 @@ class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.connections.delete(key);
|
this.connections.delete(key);
|
||||||
|
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanup(): void {
|
private cleanup(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxAge = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
for (const [hostKey, connections] of this.connections.entries()) {
|
for (const [hostKey, connections] of this.connections.entries()) {
|
||||||
const activeConnections = connections.filter((conn) => {
|
const activeConnections = connections.filter((conn) => {
|
||||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
|
||||||
try {
|
try {
|
||||||
conn.client.end();
|
conn.client.end();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -187,10 +258,14 @@ class SSHConnectionPool {
|
|||||||
} else {
|
} else {
|
||||||
this.connections.set(hostKey, activeConnections);
|
this.connections.set(hostKey, activeConnections);
|
||||||
}
|
}
|
||||||
|
this.wakeWaiter(hostKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllConnections(): void {
|
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 connections of this.connections.values()) {
|
||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
try {
|
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";
|
} from "ssh2";
|
||||||
|
|
||||||
const { BaseAgent } = ssh2Pkg;
|
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 {
|
export class MemoryAgent extends BaseAgent {
|
||||||
private key: ParsedKey;
|
private key: ParsedKey;
|
||||||
@@ -97,34 +110,59 @@ export async function applyAgentAuth(
|
|||||||
export async function performPortKnocking(
|
export async function performPortKnocking(
|
||||||
host: string,
|
host: string,
|
||||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||||
|
options: PortKnockingOptions = {},
|
||||||
): Promise<void> {
|
): 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) {
|
for (const knock of sequence) {
|
||||||
const protocol = knock.protocol || "tcp";
|
const port = Number(knock.port);
|
||||||
const delay = knock.delay ?? 100;
|
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) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (protocol === "udp") {
|
if (protocol === "udp") {
|
||||||
const client = dgram.createSocket("udp4");
|
const client = createUdpSocket();
|
||||||
client.send(Buffer.alloc(0), knock.port, host, () => {
|
let settled = false;
|
||||||
|
const timeout = setTimeout(() => finish(), udpTimeoutMs);
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
client.close();
|
client.close();
|
||||||
resolve();
|
resolve();
|
||||||
|
};
|
||||||
|
client.once("error", finish);
|
||||||
|
client.send(Buffer.alloc(0), port, host, () => {
|
||||||
|
finish();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const socket = new net.Socket();
|
const socket = createTcpSocket();
|
||||||
socket.once("connect", () => {
|
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();
|
socket.destroy();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
};
|
||||||
socket.once("error", () => {
|
socket.once("connect", finish);
|
||||||
socket.destroy();
|
socket.once("error", finish);
|
||||||
resolve();
|
socket.connect(port, host);
|
||||||
});
|
|
||||||
socket.connect(knock.port, host);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (delay > 0) {
|
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";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
// Stub all external imports before loading the module under test
|
// 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 });
|
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
|
||||||
|
|
||||||
vi.mock("../database/db/index.js", () => ({
|
vi.mock("../database/db/index.js", () => ({
|
||||||
@@ -29,21 +30,25 @@ vi.mock("../utils/logger.js", () => ({
|
|||||||
// Mock individual fs.promises methods via a stub object
|
// Mock individual fs.promises methods via a stub object
|
||||||
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
||||||
const mockWriteFile = 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", () => ({
|
vi.mock("fs", () => ({
|
||||||
default: {
|
default: {
|
||||||
promises: {
|
promises: {
|
||||||
mkdir: mockMkdir,
|
mkdir: mockMkdir,
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
|
appendFile: mockAppendFile,
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
unlink: vi.fn(),
|
unlink: mockUnlink,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
promises: {
|
promises: {
|
||||||
mkdir: mockMkdir,
|
mkdir: mockMkdir,
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
|
appendFile: mockAppendFile,
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
unlink: vi.fn(),
|
unlink: mockUnlink,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -55,7 +60,8 @@ describe("TerminalSessionManager - session logging", () => {
|
|||||||
// Re-apply resolved values after clearAllMocks
|
// Re-apply resolved values after clearAllMocks
|
||||||
mockMkdir.mockResolvedValue(undefined);
|
mockMkdir.mockResolvedValue(undefined);
|
||||||
mockWriteFile.mockResolvedValue(undefined);
|
mockWriteFile.mockResolvedValue(undefined);
|
||||||
mockInsertValues.mockResolvedValue(undefined);
|
mockReturning.mockResolvedValue([{ id: 1 }]);
|
||||||
|
mockInsertValues.mockReturnValue({ returning: mockReturning });
|
||||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type Client, type ClientChannel } from "ssh2";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sessionRecordings } from "../database/db/schema.js";
|
import { sessionRecordings } from "../database/db/schema.js";
|
||||||
@@ -36,6 +37,12 @@ export interface TerminalSession {
|
|||||||
|
|
||||||
outputBuffer: string[];
|
outputBuffer: string[];
|
||||||
outputBufferBytes: number;
|
outputBufferBytes: number;
|
||||||
|
recordingPath: string | null;
|
||||||
|
recordingHeader: string | null;
|
||||||
|
recordingBytes: number;
|
||||||
|
recordingId: number | null;
|
||||||
|
recordingWriteChain: Promise<void>;
|
||||||
|
recordingPersistChain: Promise<void>;
|
||||||
tmuxSessionName: string | null;
|
tmuxSessionName: string | null;
|
||||||
sessionLoggingEnabled: boolean;
|
sessionLoggingEnabled: boolean;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
@@ -117,6 +124,19 @@ class TerminalSessionManager {
|
|||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = Date.now();
|
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 = {
|
const session: TerminalSession = {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
@@ -135,6 +155,12 @@ class TerminalSessionManager {
|
|||||||
detachTimeout: null,
|
detachTimeout: null,
|
||||||
outputBuffer: [],
|
outputBuffer: [],
|
||||||
outputBufferBytes: 0,
|
outputBufferBytes: 0,
|
||||||
|
recordingPath,
|
||||||
|
recordingHeader,
|
||||||
|
recordingBytes: 0,
|
||||||
|
recordingId: null,
|
||||||
|
recordingWriteChain: Promise.resolve(),
|
||||||
|
recordingPersistChain: Promise.resolve(),
|
||||||
tmuxSessionName: null,
|
tmuxSessionName: null,
|
||||||
sessionLoggingEnabled,
|
sessionLoggingEnabled,
|
||||||
sessionStartedAt: now,
|
sessionStartedAt: now,
|
||||||
@@ -334,6 +360,9 @@ class TerminalSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.maybePersistLog(session, true);
|
this.maybePersistLog(session, true);
|
||||||
|
if (session.recordingPath && session.recordingBytes === 0) {
|
||||||
|
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (session.sshStream) {
|
if (session.sshStream) {
|
||||||
try {
|
try {
|
||||||
@@ -376,65 +405,51 @@ 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 {
|
private maybePersistLog(session: TerminalSession, force = false): void {
|
||||||
if (!session.sessionLoggingEnabled) return;
|
if (!session.sessionLoggingEnabled) return;
|
||||||
if (session.outputBufferBytes === 0) return;
|
if (session.recordingBytes === 0) return;
|
||||||
// Only save if new output arrived since last persist (unless forced)
|
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
||||||
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
|
session.lastPersistedBytes = session.recordingBytes;
|
||||||
return;
|
session.recordingPersistChain = session.recordingPersistChain
|
||||||
const snapshot = session.outputBuffer.join("");
|
.then(() => this.persistSessionLog(session))
|
||||||
session.lastPersistedBytes = session.outputBufferBytes;
|
.catch((err) => {
|
||||||
this.persistSessionLog(session, snapshot).catch((err) => {
|
sshLogger.warn("Failed to persist session log", {
|
||||||
sshLogger.warn("Failed to persist session log", {
|
operation: "session_log_persist_error",
|
||||||
operation: "session_log_persist_error",
|
sessionId: session.id,
|
||||||
sessionId: session.id,
|
error: err instanceof Error ? err.message : String(err),
|
||||||
error: err instanceof Error ? err.message : String(err),
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSessionLog(
|
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
||||||
session: TerminalSession,
|
if (!session.recordingPath) return;
|
||||||
logContent: string,
|
await session.recordingWriteChain;
|
||||||
): 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");
|
|
||||||
|
|
||||||
const endedAt = Date.now();
|
const endedAt = Date.now();
|
||||||
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
await db.insert(sessionRecordings).values({
|
if (session.recordingId == null) {
|
||||||
hostId: session.hostId,
|
const rows = await db
|
||||||
userId: session.userId,
|
.insert(sessionRecordings)
|
||||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
.values({
|
||||||
endedAt: new Date(endedAt).toISOString(),
|
hostId: session.hostId,
|
||||||
duration,
|
userId: session.userId,
|
||||||
recordingPath: logFile,
|
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||||
});
|
endedAt: new Date(endedAt).toISOString(),
|
||||||
|
duration,
|
||||||
|
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) {
|
} catch (err) {
|
||||||
sshLogger.warn("Failed to insert session recording row", {
|
sshLogger.warn("Failed to insert session recording row", {
|
||||||
operation: "session_recording_insert_error",
|
operation: "session_recording_insert_error",
|
||||||
@@ -449,7 +464,7 @@ class TerminalSessionManager {
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
hostId: session.hostId,
|
hostId: session.hostId,
|
||||||
duration,
|
duration,
|
||||||
bytes: cleaned.length,
|
bytes: session.recordingBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +492,47 @@ class TerminalSessionManager {
|
|||||||
const removed = session.outputBuffer.shift();
|
const removed = session.outputBuffer.shift();
|
||||||
if (removed) session.outputBufferBytes -= removed.length;
|
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 {
|
flushBuffer(session: TerminalSession): string | null {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||||
import { triggerLoginAlert } from "../utils/alert-trigger.js";
|
import { triggerLoginAlert } from "../utils/alert-trigger.js";
|
||||||
|
import { isRetriableDnsError, resolveHostForSshConnect } from "./ssh-dns.js";
|
||||||
|
|
||||||
interface ConnectToHostData {
|
interface ConnectToHostData {
|
||||||
cols: number;
|
cols: number;
|
||||||
@@ -538,6 +539,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
case "input": {
|
case "input": {
|
||||||
const inputData = data as string;
|
const inputData = data as string;
|
||||||
|
if (currentSessionId) {
|
||||||
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
|
}
|
||||||
const inputStream =
|
const inputStream =
|
||||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||||
if (inputStream) {
|
if (inputStream) {
|
||||||
@@ -1142,6 +1146,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
socks5Username?: string;
|
socks5Username?: string;
|
||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: unknown;
|
socks5ProxyChain?: unknown;
|
||||||
|
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
|
||||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||||
enableSessionLogging?: boolean;
|
enableSessionLogging?: boolean;
|
||||||
})
|
})
|
||||||
@@ -1185,6 +1190,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||||
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) {
|
} catch (error) {
|
||||||
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
|
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}`);
|
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}`);
|
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
|
||||||
|
|
||||||
sshConn.on("ready", () => {
|
sshConn.on("ready", () => {
|
||||||
@@ -2305,7 +2357,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
|
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
|
||||||
|
|
||||||
const connectConfig: Record<string, unknown> = {
|
const connectConfig: Record<string, unknown> = {
|
||||||
host: ip,
|
host: connectHost,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
||||||
@@ -2864,6 +2916,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (session) {
|
if (session) {
|
||||||
session.cols = data.cols;
|
session.cols = data.cols;
|
||||||
session.rows = data.rows;
|
session.rows = data.rows;
|
||||||
|
sessionManager.bufferResize(session.id, data.cols, data.rows);
|
||||||
}
|
}
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
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 { 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", () => {
|
describe("tmux command path handling", () => {
|
||||||
it("adds common non-login shell tmux paths", () => {
|
it("adds common non-login shell tmux paths", () => {
|
||||||
const command = withTmuxPath("command -v tmux");
|
const command = withTmuxPath("command -v tmux");
|
||||||
|
|
||||||
|
expect(command).toMatch(/^\/bin\/sh -c '/);
|
||||||
expect(command).toContain("/opt/homebrew/bin");
|
expect(command).toContain("/opt/homebrew/bin");
|
||||||
expect(command).toContain("/usr/local/bin");
|
expect(command).toContain("/usr/local/bin");
|
||||||
expect(command).toContain("/opt/bin");
|
expect(command).toContain("/opt/bin");
|
||||||
expect(command).toContain("/usr/pkg/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", () => {
|
it("wraps tmux invocations with the same path", () => {
|
||||||
expect(tmuxCommand("list-sessions")).toMatch(
|
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 {
|
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 {
|
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> {
|
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||||
try {
|
try {
|
||||||
await execCommand(conn, withTmuxPath("command -v tmux"));
|
await execCommand(conn, tmuxCommand("-V"));
|
||||||
} catch {
|
} catch {
|
||||||
return { available: false, sessions: [] };
|
return { available: false, sessions: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
type SOCKS5Config,
|
type SOCKS5Config,
|
||||||
} from "../utils/socks5-helper.js";
|
} from "../utils/socks5-helper.js";
|
||||||
import { withConnection } from "./ssh-connection-pool.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 {
|
import {
|
||||||
SEP,
|
SEP,
|
||||||
parseSessions,
|
parseSessions,
|
||||||
@@ -95,6 +96,8 @@ async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
|
|||||||
}
|
}
|
||||||
} else if (host.authType === "none") {
|
} else if (host.authType === "none") {
|
||||||
// no credentials needed
|
// no credentials needed
|
||||||
|
} else if (host.authType === "vault") {
|
||||||
|
// cert auth setup happens in connectToHost (needs client instance)
|
||||||
} else if (host.authType === "agent") {
|
} else if (host.authType === "agent") {
|
||||||
const result = await applyAgentAuth(
|
const result = await applyAgentAuth(
|
||||||
base as Record<string, unknown>,
|
base as Record<string, unknown>,
|
||||||
@@ -118,6 +121,12 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
|
|||||||
const config = await buildSshConfig(host);
|
const config = await buildSshConfig(host);
|
||||||
const client = new Client();
|
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 =
|
const proxyConfig: SOCKS5Config | null =
|
||||||
host.useSocks5 &&
|
host.useSocks5 &&
|
||||||
(host.socks5Host ||
|
(host.socks5Host ||
|
||||||
@@ -201,8 +210,17 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
|
|||||||
client.connect(config);
|
client.connect(config);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else if (config.sock) {
|
||||||
client.connect(config);
|
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> {
|
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execCommand(conn, withTmuxPath("command -v tmux"));
|
await execCommand(conn, tmuxCommand("-V"));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
handleC2SRelayTest,
|
handleC2SRelayTest,
|
||||||
type C2SOpenMessage,
|
type C2SOpenMessage,
|
||||||
} from "./tunnel-c2s-relay.js";
|
} from "./tunnel-c2s-relay.js";
|
||||||
|
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
||||||
import { handleSocks5Connect } from "./tunnel-socks5-relay.js";
|
import { handleSocks5Connect } from "./tunnel-socks5-relay.js";
|
||||||
|
|
||||||
const app = express();
|
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);
|
conn.connect(connOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1693,6 +1715,10 @@ async function killRemoteTunnelByMarker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!connOptions.sock) {
|
||||||
|
await resolveSshConnectConfigHost(connOptions);
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<Client>((resolve, reject) => {
|
return new Promise<Client>((resolve, reject) => {
|
||||||
const conn = new Client();
|
const conn = new Client();
|
||||||
conn.on("ready", () => resolve(conn));
|
conn.on("ready", () => resolve(conn));
|
||||||
@@ -1937,6 +1963,7 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tunnelName = tunnelConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
|
tunnelConfig.requestingUserId = userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||||
@@ -1967,10 +1994,6 @@ app.post(
|
|||||||
});
|
});
|
||||||
return res.status(403).json({ error: "Access denied to this host" });
|
return res.status(403).json({ error: "Access denied to this host" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accessInfo.isShared && !accessInfo.isOwner) {
|
|
||||||
tunnelConfig.requestingUserId = userId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingTunnelOperations.has(tunnelName)) {
|
if (pendingTunnelOperations.has(tunnelName)) {
|
||||||
@@ -2033,22 +2056,47 @@ app.post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (!endpointHost.id) {
|
||||||
|
throw new Error("Endpoint host not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointAccess = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
endpointHost.id,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
|
if (!endpointAccess.hasAccess) {
|
||||||
|
tunnelLogger.warn(
|
||||||
|
"User attempted tunnel connect without endpoint access",
|
||||||
|
{
|
||||||
|
operation: "tunnel_connect_endpoint_unauthorized",
|
||||||
|
userId,
|
||||||
|
hostId: endpointHost.id,
|
||||||
|
tunnelName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new Error("Endpoint host not found");
|
||||||
|
}
|
||||||
|
|
||||||
tunnelConfig.endpointIP = endpointHost.ip;
|
tunnelConfig.endpointIP = endpointHost.ip;
|
||||||
tunnelConfig.endpointSSHPort = endpointHost.port;
|
tunnelConfig.endpointSSHPort = endpointHost.port;
|
||||||
tunnelConfig.endpointUsername = endpointHost.username;
|
tunnelConfig.endpointUsername = endpointHost.username;
|
||||||
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
||||||
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
||||||
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
|
tunnelConfig.endpointCredentialId =
|
||||||
tunnelConfig.endpointUserId = endpointHost.userId;
|
endpointHost.userId === userId
|
||||||
|
? endpointHost.credentialId
|
||||||
|
: undefined;
|
||||||
|
tunnelConfig.endpointUserId = userId;
|
||||||
|
|
||||||
// Resolve credentials server-side instead of from HTTP response
|
// Resolve credentials server-side instead of from HTTP response
|
||||||
if (endpointHost.id && endpointHost.userId) {
|
if (endpointHost.id) {
|
||||||
try {
|
try {
|
||||||
const { resolveHostById } =
|
const { resolveHostById } =
|
||||||
await import("./host-resolver.js");
|
await import("./host-resolver.js");
|
||||||
const resolved = await resolveHostById(
|
const resolved = await resolveHostById(
|
||||||
endpointHost.id,
|
endpointHost.id,
|
||||||
endpointHost.userId,
|
userId,
|
||||||
);
|
);
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
tunnelConfig.endpointPassword = resolved.password;
|
tunnelConfig.endpointPassword = resolved.password;
|
||||||
|
|||||||
@@ -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 bcrypt from "bcryptjs";
|
||||||
import { db } from "../database/db/index.js";
|
import { db } from "../database/db/index.js";
|
||||||
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.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 { nanoid } from "nanoid";
|
||||||
import type { DeviceType } from "./user-agent-parser.js";
|
import type { DeviceType } from "./user-agent-parser.js";
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ interface WrappedDataKey {
|
|||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
pendingTOTP?: boolean;
|
pendingTOTP?: boolean;
|
||||||
dataKey?: Buffer;
|
dataKey?: Buffer;
|
||||||
}
|
}
|
||||||
@@ -345,6 +346,9 @@ class AuthManager {
|
|||||||
rememberMe?: boolean;
|
rememberMe?: boolean;
|
||||||
deviceType?: DeviceType;
|
deviceType?: DeviceType;
|
||||||
deviceInfo?: string;
|
deviceInfo?: string;
|
||||||
|
oidcSub?: string | null;
|
||||||
|
oidcSid?: string | null;
|
||||||
|
ssoProviderId?: number | null;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||||
@@ -391,6 +395,9 @@ class AuthManager {
|
|||||||
jwtToken: token,
|
jwtToken: token,
|
||||||
deviceType: options.deviceType,
|
deviceType: options.deviceType,
|
||||||
deviceInfo: options.deviceInfo,
|
deviceInfo: options.deviceInfo,
|
||||||
|
oidcSub: options.oidcSub ?? null,
|
||||||
|
oidcSid: options.oidcSid ?? null,
|
||||||
|
ssoProviderId: options.ssoProviderId ?? null,
|
||||||
createdAt,
|
createdAt,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
lastActiveAt: createdAt,
|
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> {
|
async cleanupExpiredSessions(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const expiredSessions = await db
|
const expiredSessions = await db
|
||||||
@@ -819,6 +882,7 @@ class AuthManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
req.userId = matchedKey.userId;
|
req.userId = matchedKey.userId;
|
||||||
|
req.apiKeyId = matchedKey.id;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("API key authentication failed", error, {
|
databaseLogger.error("API key authentication failed", error, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { statsLogger } from "./logger.js";
|
import { statsLogger } from "./logger.js";
|
||||||
|
import { safeOutboundFetch } from "./safe-outbound-fetch.js";
|
||||||
|
|
||||||
export interface AlertPayload {
|
export interface AlertPayload {
|
||||||
hostName: string;
|
hostName: string;
|
||||||
@@ -36,7 +37,7 @@ async function fetchWithRetry(
|
|||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const res = await fetch(url, options);
|
const res = await safeOutboundFetch(url, options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
getRequestBasePath,
|
||||||
getRequestBaseUrl,
|
getRequestBaseUrl,
|
||||||
getRequestBaseUrlWithForceHTTPS,
|
getRequestBaseUrlWithForceHTTPS,
|
||||||
|
getRequestOrigin,
|
||||||
normalizeBasePath,
|
normalizeBasePath,
|
||||||
} from "./request-origin.js";
|
} from "./request-origin.js";
|
||||||
|
|
||||||
@@ -106,3 +107,52 @@ describe("getRequestBasePath", () => {
|
|||||||
).toBe("https://example.com/termix");
|
).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();
|
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 {
|
export function normalizeBasePath(value: unknown): string {
|
||||||
if (typeof value !== "string") return "";
|
if (typeof value !== "string") return "";
|
||||||
let basePath = value.split(",")[0].trim();
|
let basePath = value.split(",")[0].trim();
|
||||||
@@ -45,38 +83,23 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
|||||||
: "http";
|
: "http";
|
||||||
}
|
}
|
||||||
|
|
||||||
const portHeader = req.headers["x-forwarded-port"];
|
let port = normalizePort(req.headers["x-forwarded-port"]);
|
||||||
let port: string | undefined =
|
const { host, port: hostPort } = splitHostHeader(
|
||||||
typeof portHeader === "string"
|
req.headers["x-forwarded-host"] || req.headers.host,
|
||||||
? portHeader.split(",")[0].trim()
|
);
|
||||||
: undefined;
|
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) {
|
if (port) {
|
||||||
const isDefaultPort =
|
const isDefaultPort =
|
||||||
(protocol === "http" && port === "80") ||
|
(protocol === "http" && port === "80") ||
|
||||||
(protocol === "https" && port === "443");
|
(protocol === "https" && port === "443");
|
||||||
|
|
||||||
return isDefaultPort
|
return isDefaultPort
|
||||||
? `${protocol}://${hostWithoutPort}`
|
? `${protocol}://${host}`
|
||||||
: `${protocol}://${hostWithoutPort}:${port}`;
|
: `${protocol}://${host}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${protocol}://${hostWithoutPort}`;
|
return `${protocol}://${host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequestOriginWithForceHTTPS(
|
export function getRequestOriginWithForceHTTPS(
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { lookup } from "dns";
|
||||||
|
import { BlockList, isIP } from "net";
|
||||||
|
import { Agent } from "undici";
|
||||||
|
|
||||||
|
const blockedAddresses = new BlockList();
|
||||||
|
|
||||||
|
for (const [network, prefix] of [
|
||||||
|
["0.0.0.0", 8],
|
||||||
|
["10.0.0.0", 8],
|
||||||
|
["100.64.0.0", 10],
|
||||||
|
["127.0.0.0", 8],
|
||||||
|
["169.254.0.0", 16],
|
||||||
|
["172.16.0.0", 12],
|
||||||
|
["192.168.0.0", 16],
|
||||||
|
["198.18.0.0", 15],
|
||||||
|
["224.0.0.0", 4],
|
||||||
|
["240.0.0.0", 4],
|
||||||
|
] as const) {
|
||||||
|
blockedAddresses.addSubnet(network, prefix, "ipv4");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [network, prefix] of [
|
||||||
|
["::", 128],
|
||||||
|
["::1", 128],
|
||||||
|
["::ffff:0:0", 96],
|
||||||
|
["fc00::", 7],
|
||||||
|
["fe80::", 10],
|
||||||
|
["ff00::", 8],
|
||||||
|
] as const) {
|
||||||
|
blockedAddresses.addSubnet(network, prefix, "ipv6");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedAddress(address: string): boolean {
|
||||||
|
const family = isIP(address);
|
||||||
|
return (
|
||||||
|
family === 0 ||
|
||||||
|
blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeOutboundFetch(
|
||||||
|
rawUrl: string,
|
||||||
|
options: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(url.protocol) ||
|
||||||
|
url.username ||
|
||||||
|
url.password
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid outbound URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||||
|
if (isIP(hostname) && isBlockedAddress(hostname)) {
|
||||||
|
throw new Error("Private destinations are not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatcher = new Agent({
|
||||||
|
connect: {
|
||||||
|
lookup(host, lookupOptions, callback) {
|
||||||
|
lookup(
|
||||||
|
host,
|
||||||
|
{ ...lookupOptions, all: true, verbatim: true },
|
||||||
|
(error, addresses) => {
|
||||||
|
if (error) return callback(error, "", 0);
|
||||||
|
if (
|
||||||
|
!addresses.length ||
|
||||||
|
addresses.some(({ address }) => isBlockedAddress(address))
|
||||||
|
) {
|
||||||
|
return callback(
|
||||||
|
new Error("Private destinations are not allowed"),
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const selected = addresses[0];
|
||||||
|
callback(null, selected.address, selected.family);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
...options,
|
||||||
|
redirect: "error",
|
||||||
|
dispatcher,
|
||||||
|
} as RequestInit & { dispatcher: Agent });
|
||||||
|
} finally {
|
||||||
|
await dispatcher.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getDb } from "../database/db/index.js";
|
|||||||
import { settings } from "../database/db/schema.js";
|
import { settings } from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { databaseLogger } from "./logger.js";
|
import { databaseLogger } from "./logger.js";
|
||||||
|
import { SystemCrypto } from "./system-crypto.js";
|
||||||
|
|
||||||
interface KEKSalt {
|
interface KEKSalt {
|
||||||
salt: string;
|
salt: string;
|
||||||
@@ -76,12 +77,15 @@ class UserCrypto {
|
|||||||
let DEK: Buffer;
|
let DEK: Buffer;
|
||||||
|
|
||||||
if (existingEncryptedDEK) {
|
if (existingEncryptedDEK) {
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
DEK = await this.decryptSystemWrappedDEK(
|
||||||
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
|
existingEncryptedDEK,
|
||||||
systemKey.fill(0);
|
userId,
|
||||||
|
"oidc",
|
||||||
|
`user_encrypted_dek_${userId}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
const systemKey = await this.deriveOIDCSystemKey(userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedDEK = this.encryptDEK(DEK, systemKey);
|
const encryptedDEK = this.encryptDEK(DEK, systemKey);
|
||||||
@@ -172,9 +176,12 @@ class UserCrypto {
|
|||||||
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
||||||
|
|
||||||
if (oidcEncryptedDEK) {
|
if (oidcEncryptedDEK) {
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
const DEK = await this.decryptSystemWrappedDEK(
|
||||||
const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey);
|
oidcEncryptedDEK,
|
||||||
systemKey.fill(0);
|
userId,
|
||||||
|
"oidc",
|
||||||
|
`user_encrypted_dek_oidc_${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
@@ -210,9 +217,12 @@ class UserCrypto {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
const DEK = await this.decryptSystemWrappedDEK(
|
||||||
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
encryptedDEK,
|
||||||
systemKey.fill(0);
|
userId,
|
||||||
|
"oidc",
|
||||||
|
`user_encrypted_dek_${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
await this.setupOIDCUserEncryption(userId, sessionDurationMs);
|
||||||
@@ -254,7 +264,7 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
const systemKey = await this.deriveWebAuthnSystemKey(userId);
|
||||||
const encryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
const encryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
@@ -271,9 +281,12 @@ class UserCrypto {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
const DEK = await this.decryptSystemWrappedDEK(
|
||||||
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
encryptedDEK,
|
||||||
systemKey.fill(0);
|
userId,
|
||||||
|
"webauthn",
|
||||||
|
`user_encrypted_dek_webauthn_${userId}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -452,7 +465,7 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = this.deriveOIDCSystemKey(userId);
|
const systemKey = await this.deriveOIDCSystemKey(userId);
|
||||||
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
@@ -556,12 +569,68 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveOIDCSystemKey(userId: string): Buffer {
|
private async deriveOIDCSystemKey(userId: string): Promise<Buffer> {
|
||||||
const systemSecret =
|
if (process.env.OIDC_SYSTEM_SECRET) {
|
||||||
process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default";
|
return crypto.pbkdf2Sync(
|
||||||
const salt = Buffer.from(userId, "utf8");
|
process.env.OIDC_SYSTEM_SECRET,
|
||||||
|
Buffer.from(userId, "utf8"),
|
||||||
|
100000,
|
||||||
|
UserCrypto.KEK_LENGTH,
|
||||||
|
"sha256",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemSecret = await SystemCrypto.getInstance().getEncryptionKey();
|
||||||
|
return Buffer.from(
|
||||||
|
crypto.hkdfSync(
|
||||||
|
"sha256",
|
||||||
|
systemSecret,
|
||||||
|
Buffer.from(userId, "utf8"),
|
||||||
|
Buffer.from("termix:oidc-user-kek", "utf8"),
|
||||||
|
UserCrypto.KEK_LENGTH,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deriveWebAuthnSystemKey(userId: string): Promise<Buffer> {
|
||||||
|
const configuredSecret =
|
||||||
|
process.env.WEBAUTHN_SYSTEM_SECRET || process.env.OIDC_SYSTEM_SECRET;
|
||||||
|
if (configuredSecret) {
|
||||||
|
return crypto.pbkdf2Sync(
|
||||||
|
configuredSecret,
|
||||||
|
Buffer.from(`webauthn:${userId}`, "utf8"),
|
||||||
|
100000,
|
||||||
|
UserCrypto.KEK_LENGTH,
|
||||||
|
"sha256",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemSecret = await SystemCrypto.getInstance().getEncryptionKey();
|
||||||
|
return Buffer.from(
|
||||||
|
crypto.hkdfSync(
|
||||||
|
"sha256",
|
||||||
|
systemSecret,
|
||||||
|
Buffer.from(userId, "utf8"),
|
||||||
|
Buffer.from("termix:webauthn-user-kek", "utf8"),
|
||||||
|
UserCrypto.KEK_LENGTH,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveLegacySystemKey(
|
||||||
|
userId: string,
|
||||||
|
type: "oidc" | "webauthn",
|
||||||
|
): Buffer {
|
||||||
|
const secret =
|
||||||
|
type === "oidc"
|
||||||
|
? "termix-oidc-system-secret-default"
|
||||||
|
: "termix-webauthn-system-secret-default";
|
||||||
|
const salt = Buffer.from(
|
||||||
|
type === "oidc" ? userId : `webauthn:${userId}`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
return crypto.pbkdf2Sync(
|
return crypto.pbkdf2Sync(
|
||||||
systemSecret,
|
secret,
|
||||||
salt,
|
salt,
|
||||||
100000,
|
100000,
|
||||||
UserCrypto.KEK_LENGTH,
|
UserCrypto.KEK_LENGTH,
|
||||||
@@ -569,19 +638,39 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveWebAuthnSystemKey(userId: string): Buffer {
|
private async decryptSystemWrappedDEK(
|
||||||
const systemSecret =
|
encryptedDEK: EncryptedDEK,
|
||||||
process.env.WEBAUTHN_SYSTEM_SECRET ||
|
userId: string,
|
||||||
process.env.OIDC_SYSTEM_SECRET ||
|
type: "oidc" | "webauthn",
|
||||||
"termix-webauthn-system-secret-default";
|
settingKey: string,
|
||||||
const salt = Buffer.from(`webauthn:${userId}`, "utf8");
|
): Promise<Buffer> {
|
||||||
return crypto.pbkdf2Sync(
|
const systemKey =
|
||||||
systemSecret,
|
type === "oidc"
|
||||||
salt,
|
? await this.deriveOIDCSystemKey(userId)
|
||||||
100000,
|
: await this.deriveWebAuthnSystemKey(userId);
|
||||||
UserCrypto.KEK_LENGTH,
|
try {
|
||||||
"sha256",
|
return this.decryptDEK(encryptedDEK, systemKey);
|
||||||
);
|
} catch {
|
||||||
|
const legacyKey = this.deriveLegacySystemKey(userId, type);
|
||||||
|
try {
|
||||||
|
const dek = this.decryptDEK(encryptedDEK, legacyKey);
|
||||||
|
const value = JSON.stringify(this.encryptDEK(dek, systemKey));
|
||||||
|
await getDb()
|
||||||
|
.update(settings)
|
||||||
|
.set({ value })
|
||||||
|
.where(eq(settings.key, settingKey));
|
||||||
|
databaseLogger.info("Migrated legacy system-wrapped user key", {
|
||||||
|
operation: "user_key_wrap_migrated",
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
return dek;
|
||||||
|
} finally {
|
||||||
|
legacyKey.fill(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
systemKey.fill(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
|
private encryptDEK(dek: Buffer, kek: Buffer): EncryptedDEK {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Toaster } from "@/components/sonner";
|
|||||||
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
||||||
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
|
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
|
||||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||||
|
import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom";
|
||||||
import type { FontSizeId } from "@/types/ui-types";
|
import type { FontSizeId } from "@/types/ui-types";
|
||||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
import { useServiceWorker } from "@/hooks/use-service-worker";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -340,6 +341,8 @@ function RootApp() {
|
|||||||
return <App />;
|
return <App />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installElectronWheelZoomGuard();
|
||||||
|
|
||||||
prepareClientCacheVersion().finally(() => {
|
prepareClientCacheVersion().finally(() => {
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
Vendored
+20
@@ -41,6 +41,26 @@ declare module "guacamole-common-js" {
|
|||||||
constructor(url: string);
|
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 {
|
class Mouse {
|
||||||
constructor(element: HTMLElement);
|
constructor(element: HTMLElement);
|
||||||
onmousedown: ((state: Mouse.State) => void) | null;
|
onmousedown: ((state: Mouse.State) => void) | null;
|
||||||
|
|||||||
+5
-1
@@ -109,7 +109,8 @@ export interface Host {
|
|||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
| "tailscale"
|
| "tailscale"
|
||||||
| "agent";
|
| "agent"
|
||||||
|
| "vault";
|
||||||
useWarpgate?: boolean;
|
useWarpgate?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -123,6 +124,8 @@ export interface Host {
|
|||||||
autostartKeyPassword?: string;
|
autostartKeyPassword?: string;
|
||||||
|
|
||||||
credentialId?: number;
|
credentialId?: number;
|
||||||
|
vaultProfileId?: number | null;
|
||||||
|
vaultProfile?: { id?: number | null };
|
||||||
overrideCredentialUsername?: boolean;
|
overrideCredentialUsername?: boolean;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
enableTerminal: boolean;
|
enableTerminal: boolean;
|
||||||
@@ -952,6 +955,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
userId: string;
|
userId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
apiKeyId?: string;
|
||||||
user?: {
|
user?: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
+296
-202
@@ -6,30 +6,101 @@ import { Separator } from "@/components/separator";
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Sheet, SheetContent } from "@/components/sheet";
|
import { Sheet, SheetContent } from "@/components/sheet";
|
||||||
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
|
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 { createPortal } from "react-dom";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
||||||
import { CommandPalette } from "@/shell/CommandPalette";
|
|
||||||
import { AppRail } from "@/sidebar/AppRail";
|
import { AppRail } from "@/sidebar/AppRail";
|
||||||
import type { RailView } 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 { SplitView } from "@/shell/SplitView";
|
||||||
import { renderTabContent } from "@/shell/tabUtils";
|
import { renderTabContent } from "@/shell/tabUtils";
|
||||||
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager";
|
|
||||||
import { TabBar } from "@/shell/TabBar";
|
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 {
|
import type {
|
||||||
Tab,
|
Tab,
|
||||||
TabType,
|
TabType,
|
||||||
@@ -59,10 +130,10 @@ import {
|
|||||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
import type { SSHHostWithStatus } from "@/main-axios";
|
||||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||||
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
|
|
||||||
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
||||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
|
import { changeAppLanguage } from "@/i18n/i18n";
|
||||||
|
|
||||||
function buildHostTree(
|
function buildHostTree(
|
||||||
hosts: SSHHostWithStatus[],
|
hosts: SSHHostWithStatus[],
|
||||||
@@ -618,8 +689,7 @@ export function AppShell({
|
|||||||
applyAccentColor(prefs.accentColor);
|
applyAccentColor(prefs.accentColor);
|
||||||
}
|
}
|
||||||
if (prefs.language && prefs.language !== i18n.language) {
|
if (prefs.language && prefs.language !== i18n.language) {
|
||||||
localStorage.setItem("i18nextLng", prefs.language);
|
void changeAppLanguage(prefs.language);
|
||||||
void i18n.changeLanguage(prefs.language);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
prefs.commandAutocomplete !== null &&
|
prefs.commandAutocomplete !== null &&
|
||||||
@@ -738,8 +808,17 @@ export function AppShell({
|
|||||||
}, [loadHosts]);
|
}, [loadHosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("termix:hosts-changed", loadHosts);
|
const onHostsChanged = () => {
|
||||||
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
|
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]);
|
}, [loadHosts]);
|
||||||
|
|
||||||
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
|
||||||
@@ -1437,169 +1516,174 @@ export function AppShell({
|
|||||||
|
|
||||||
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
|
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
|
||||||
const sidebarPanelContent = (
|
const sidebarPanelContent = (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<Suspense fallback={<SidebarPanelFallback />}>
|
||||||
<div
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
<div
|
||||||
>
|
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
||||||
<HostsPanel
|
>
|
||||||
onOpenTab={(host, type) => {
|
<HostsPanel
|
||||||
connectHost(host, type);
|
onOpenTab={(host, type) => {
|
||||||
if (isMobile) setSidebarOpen(false);
|
connectHost(host, type);
|
||||||
}}
|
|
||||||
onEditHost={editHostInManager}
|
|
||||||
hostTree={realHostTree ?? undefined}
|
|
||||||
loading={hostsLoading}
|
|
||||||
onEditingChange={setSidebarEditing}
|
|
||||||
active={railView === "hosts"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
|
|
||||||
>
|
|
||||||
<CredentialsPanel
|
|
||||||
onEditingChange={setSidebarEditing}
|
|
||||||
active={railView === "credentials"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{railView === "termix-id" && (
|
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
|
||||||
<TermixIdPanel />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "serial" && (
|
|
||||||
<SerialPanel
|
|
||||||
onConnect={(config) => {
|
|
||||||
openSerialTab(config);
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "quick-connect" && (
|
|
||||||
<QuickConnectPanel
|
|
||||||
onConnect={(host, type) => {
|
|
||||||
openTab(host, type);
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "ssh-tools" && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<SshToolsPanel
|
|
||||||
terminalTabs={terminalTabs}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "snippets" && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<SnippetsPanel
|
|
||||||
terminalTabs={terminalTabs}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "history" && (
|
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "split-screen" && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<SplitScreenPanel
|
|
||||||
tabs={tabs}
|
|
||||||
splitMode={splitMode}
|
|
||||||
setSplitMode={setSplitMode}
|
|
||||||
paneTabIds={paneTabIds}
|
|
||||||
setPaneTabIds={setPaneTabIds}
|
|
||||||
onAssignPane={assignPane}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "connections" && (
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
||||||
<ConnectionsPanel
|
|
||||||
tabs={tabs}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
allHosts={allHosts}
|
|
||||||
backgroundTabRecords={backgroundTabRecords}
|
|
||||||
onSwitchToTab={(tabId) => {
|
|
||||||
setActiveTabId(tabId);
|
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onCloseTab={closeTab}
|
onEditHost={editHostInManager}
|
||||||
onReopenTab={(record, restoredSessionId) => {
|
hostTree={realHostTree ?? undefined}
|
||||||
const host = record.hostId
|
loading={hostsLoading}
|
||||||
? allHosts.find((h) => h.id === String(record.hostId))
|
onEditingChange={setSidebarEditing}
|
||||||
: undefined;
|
active={railView === "hosts"}
|
||||||
const hostlessTypes: TabType[] = ["tunnel"];
|
/>
|
||||||
if (!host && !hostlessTypes.includes(record.tabType as TabType))
|
</div>
|
||||||
return;
|
|
||||||
setBackgroundTabRecords((prev) =>
|
<div
|
||||||
prev.filter((r) => r.id !== record.id),
|
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
|
||||||
);
|
>
|
||||||
if (host) {
|
<CredentialsPanel
|
||||||
const effectiveSessionId =
|
onEditingChange={setSidebarEditing}
|
||||||
restoredSessionId ?? record.backendSessionId ?? null;
|
active={railView === "credentials"}
|
||||||
openTab(host, record.tabType as TabType, {
|
/>
|
||||||
instanceId: record.id,
|
</div>
|
||||||
restoredSessionId: effectiveSessionId,
|
|
||||||
savedLabel: record.label,
|
{railView === "termix-id" && (
|
||||||
});
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
} else {
|
<TermixIdPanel />
|
||||||
openSingletonTab(record.tabType as TabType);
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
|
{railView === "serial" && (
|
||||||
|
<SerialPanel
|
||||||
|
onConnect={(config) => {
|
||||||
|
openSerialTab(config);
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onForgetBackground={(recordId) => {
|
/>
|
||||||
setBackgroundTabRecords((prev) =>
|
)}
|
||||||
prev.filter((r) => r.id !== recordId),
|
|
||||||
);
|
{railView === "quick-connect" && (
|
||||||
|
<QuickConnectPanel
|
||||||
|
onConnect={(host, type) => {
|
||||||
|
openTab(host, type);
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onRenameTab={renameTab}
|
|
||||||
onReorderTabs={setTabs}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "session-logs" && (
|
{railView === "ssh-tools" && (
|
||||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<SessionLogsPanel />
|
<SshToolsPanel
|
||||||
</div>
|
terminalTabs={terminalTabs}
|
||||||
)}
|
activeTabId={activeTabId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{railView === "user-profile" && (
|
{railView === "snippets" && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<UserProfilePanel
|
<SnippetsPanel
|
||||||
username={username}
|
terminalTabs={terminalTabs}
|
||||||
onLogout={onLogout}
|
activeTabId={activeTabId}
|
||||||
onChangeServer={onChangeServer}
|
/>
|
||||||
userPrefs={userPrefs}
|
</div>
|
||||||
onPrefsChange={setUserPrefs}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{railView === "admin-settings" && isAdmin && (
|
{railView === "history" && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
<AdminSettingsPanel />
|
<HistoryPanel
|
||||||
</div>
|
terminalTabs={terminalTabs}
|
||||||
)}
|
activeTabId={activeTabId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{railView === "alerts" && (
|
{railView === "split-screen" && (
|
||||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<AlertsPanel />
|
<SplitScreenPanel
|
||||||
</div>
|
tabs={tabs}
|
||||||
)}
|
splitMode={splitMode}
|
||||||
</div>
|
setSplitMode={setSplitMode}
|
||||||
|
paneTabIds={paneTabIds}
|
||||||
|
setPaneTabIds={setPaneTabIds}
|
||||||
|
onAssignPane={assignPane}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "connections" && (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTabId}
|
||||||
|
allHosts={allHosts}
|
||||||
|
backgroundTabRecords={backgroundTabRecords}
|
||||||
|
onSwitchToTab={(tabId) => {
|
||||||
|
setActiveTabId(tabId);
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
onCloseTab={closeTab}
|
||||||
|
onReopenTab={(record, restoredSessionId) => {
|
||||||
|
const host = record.hostId
|
||||||
|
? allHosts.find((h) => h.id === String(record.hostId))
|
||||||
|
: undefined;
|
||||||
|
const hostlessTypes: TabType[] = ["tunnel"];
|
||||||
|
if (!host && !hostlessTypes.includes(record.tabType as TabType))
|
||||||
|
return;
|
||||||
|
setBackgroundTabRecords((prev) =>
|
||||||
|
prev.filter((r) => r.id !== record.id),
|
||||||
|
);
|
||||||
|
if (host) {
|
||||||
|
const effectiveSessionId =
|
||||||
|
restoredSessionId ?? record.backendSessionId ?? null;
|
||||||
|
openTab(host, record.tabType as TabType, {
|
||||||
|
instanceId: record.id,
|
||||||
|
restoredSessionId: effectiveSessionId,
|
||||||
|
savedLabel: record.label,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
openSingletonTab(record.tabType as TabType);
|
||||||
|
}
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
onForgetBackground={(recordId) => {
|
||||||
|
setBackgroundTabRecords((prev) =>
|
||||||
|
prev.filter((r) => r.id !== recordId),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onRenameTab={renameTab}
|
||||||
|
onReorderTabs={setTabs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "session-logs" && (
|
||||||
|
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||||
|
<SessionLogsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "user-profile" && (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<UserProfilePanel
|
||||||
|
username={username}
|
||||||
|
onLogout={onLogout}
|
||||||
|
onChangeServer={onChangeServer}
|
||||||
|
userPrefs={userPrefs}
|
||||||
|
onPrefsChange={setUserPrefs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "admin-settings" && isAdmin && (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<AdminSettingsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{railView === "alerts" && (
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
|
<AlertsPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sidebar header — shared
|
// Sidebar header — shared
|
||||||
@@ -1713,6 +1797,10 @@ export function AppShell({
|
|||||||
onAddToSplit={addTabToSplit}
|
onAddToSplit={addTabToSplit}
|
||||||
onRemoveFromSplit={removeTabFromSplit}
|
onRemoveFromSplit={removeTabFromSplit}
|
||||||
onRenameTab={renameTab}
|
onRenameTab={renameTab}
|
||||||
|
onOpenFileManager={(tabId) => {
|
||||||
|
const targetTab = tabs.find((t) => t.id === tabId);
|
||||||
|
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||||
|
}}
|
||||||
isAppFullscreen={isAppFullscreen}
|
isAppFullscreen={isAppFullscreen}
|
||||||
onToggleAppFullscreen={toggleAppFullscreen}
|
onToggleAppFullscreen={toggleAppFullscreen}
|
||||||
/>
|
/>
|
||||||
@@ -1808,35 +1896,41 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandPalette
|
{commandPaletteOpen && (
|
||||||
isOpen={commandPaletteOpen}
|
<Suspense fallback={null}>
|
||||||
setIsOpen={setCommandPaletteOpen}
|
<CommandPalette
|
||||||
hosts={allHosts}
|
isOpen={commandPaletteOpen}
|
||||||
onOpenTab={(type, label, pendingEvent) => {
|
setIsOpen={setCommandPaletteOpen}
|
||||||
if (
|
hosts={allHosts}
|
||||||
[
|
onOpenTab={(type, label, pendingEvent) => {
|
||||||
"dashboard",
|
if (
|
||||||
"host-manager",
|
[
|
||||||
"user-profile",
|
"dashboard",
|
||||||
"admin-settings",
|
"host-manager",
|
||||||
].includes(type)
|
"user-profile",
|
||||||
) {
|
"admin-settings",
|
||||||
openSingletonTab(type, pendingEvent);
|
].includes(type)
|
||||||
} else if (type === "tmux_monitor") {
|
) {
|
||||||
// --- tmux-monitor --- singleton tab, optionally preselecting a host
|
openSingletonTab(type, pendingEvent);
|
||||||
openSingletonTab(
|
} else if (type === "tmux_monitor") {
|
||||||
type,
|
// --- tmux-monitor --- singleton tab, optionally preselecting a host
|
||||||
undefined,
|
openSingletonTab(
|
||||||
label ? allHosts.find((h) => h.name === label) : undefined,
|
type,
|
||||||
);
|
undefined,
|
||||||
} else if (label) {
|
label ? allHosts.find((h) => h.name === label) : undefined,
|
||||||
const host = allHosts.find((h) => h.name === label);
|
);
|
||||||
if (host) openTab(host, type);
|
} else if (label) {
|
||||||
}
|
const host = allHosts.find((h) => h.name === label);
|
||||||
}}
|
if (host) openTab(host, type);
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
<TransferMonitor />
|
<TransferMonitor />
|
||||||
<AlertManager userId={userId} loggedIn={!!username} />
|
<Suspense fallback={null}>
|
||||||
|
<AlertManager userId={userId} loggedIn={!!username} />
|
||||||
|
</Suspense>
|
||||||
</ServerStatusProvider>
|
</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;
|
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<
|
export async function getNotificationChannels(): Promise<
|
||||||
NotificationChannel[]
|
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[]> {
|
export async function getAlertRules(): Promise<AlertRule[]> {
|
||||||
const res = await rbacApi.get("/alert-rules");
|
const res = await rbacApi.get("/alert-rules");
|
||||||
return (res.data as Record<string, unknown>[]).map(mapRule);
|
return (res.data as Record<string, unknown>[]).map(mapRule);
|
||||||
@@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: {
|
|||||||
acknowledged?: boolean;
|
acknowledged?: boolean;
|
||||||
}): Promise<AlertFiring[]> {
|
}): Promise<AlertFiring[]> {
|
||||||
const res = await rbacApi.get("/alert-firings", { params: opts });
|
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> {
|
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;
|
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(
|
export async function getAuditLogs(
|
||||||
filters: AuditLogFilters = {},
|
filters: AuditLogFilters = {},
|
||||||
): Promise<AuditLogResponse> {
|
): Promise<AuditLogResponse> {
|
||||||
@@ -50,7 +84,14 @@ export async function getAuditLogs(
|
|||||||
if (filters.endDate) params.set("endDate", filters.endDate);
|
if (filters.endDate) params.set("endDate", filters.endDate);
|
||||||
|
|
||||||
const response = await authApi.get(`/audit-logs?${params.toString()}`);
|
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) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch audit logs");
|
handleApiError(error, "fetch audit logs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios, { type AxiosRequestConfig } from "axios";
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
import { handleApiError, statsApi } from "@/main-axios";
|
import { handleApiError, statsApi } from "@/main-axios";
|
||||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||||
|
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||||
|
|
||||||
type ApiConnectionLog = {
|
type ApiConnectionLog = {
|
||||||
type: "info" | "success" | "warning" | "error";
|
type: "info" | "success" | "warning" | "error";
|
||||||
@@ -73,34 +74,37 @@ function isTransientStatusError(error: unknown): boolean {
|
|||||||
export async function getAllServerStatuses(): Promise<
|
export async function getAllServerStatuses(): Promise<
|
||||||
Record<number, ServerStatus>
|
Record<number, ServerStatus>
|
||||||
> {
|
> {
|
||||||
let lastError: unknown = null;
|
return getCachedServerStatuses(async () => {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await statsApi.get("/status", {
|
const response = await statsApi.get("/status", {
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
// Silence per-attempt interceptor logging & health-monitor side
|
// Silence per-attempt interceptor logging & health-monitor side
|
||||||
// effects on all attempts except the final one, so background
|
// effects on all attempts except the final one, so background
|
||||||
// blips don't look like real outages.
|
// blips don't look like real outages.
|
||||||
__silentRetry: !isFinalAttempt,
|
__silentRetry: !isFinalAttempt,
|
||||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||||
return response.data || {};
|
return response.data || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (!isTransientStatusError(error)) {
|
if (!isTransientStatusError(error)) {
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
if (pauseAfterMs === null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||||
}
|
}
|
||||||
if (pauseAfterMs === null) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
handleApiError(lastError, "fetch server statuses");
|
handleApiError(lastError, "fetch server statuses");
|
||||||
|
return {};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authApi } from "@/main-axios";
|
import { authApi } from "@/main-axios";
|
||||||
|
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||||
|
|
||||||
// OPEN TABS API
|
// OPEN TABS API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -42,6 +43,8 @@ export interface ActiveSessionInfo {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||||
|
|
||||||
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
|
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
|
||||||
const response = await authApi.get("/open-tabs");
|
const response = await authApi.get("/open-tabs");
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -69,8 +72,10 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||||
const response = await authApi.get("/open-tabs/active-sessions");
|
return activeSessionsCache.get(async () => {
|
||||||
return response.data;
|
const response = await authApi.get("/open-tabs/active-sessions");
|
||||||
|
return Array.isArray(response.data) ? response.data : [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export type SessionLogRecord = {
|
|||||||
hostName: string | null;
|
hostName: string | null;
|
||||||
hostIp: string | null;
|
hostIp: string | null;
|
||||||
sizeBytes: number | null;
|
sizeBytes: number | null;
|
||||||
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
format: "text" | "asciicast" | "guacamole";
|
||||||
|
username: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
|
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> {
|
export async function getSessionLogContent(id: number): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/session_logs/${id}/content`, {
|
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("totalSize", String(file.size));
|
||||||
form.append("chunk", chunkBlob, fileName);
|
form.append("chunk", chunkBlob, fileName);
|
||||||
|
|
||||||
const response = await fileManagerApi.post(
|
const response = await fileManagerApi.postForm(
|
||||||
"/ssh/uploadFileChunk",
|
"/ssh/uploadFileChunk",
|
||||||
form,
|
form,
|
||||||
{ timeout: 0 },
|
{ timeout: 0 },
|
||||||
|
|||||||
@@ -8,16 +8,38 @@ import {
|
|||||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||||
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
|
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
|
||||||
|
import {
|
||||||
|
getCachedSSHHosts,
|
||||||
|
invalidateHostsAndStatusCaches,
|
||||||
|
} from "@/lib/hosts-request-cache";
|
||||||
|
|
||||||
// SSH HOST MANAGEMENT
|
// SSH HOST MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
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");
|
||||||
|
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSSHHosts(
|
||||||
|
options: GetSSHHostsOptions = {},
|
||||||
|
): Promise<SSHHostWithStatus[]> {
|
||||||
|
const includeStatus = options.includeStatus !== false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hostsResponse = await sshHostApi.get("/db/host");
|
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
||||||
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
|
||||||
? hostsResponse.data
|
if (!includeStatus) {
|
||||||
: [];
|
return hosts.map((host) => ({
|
||||||
|
...host,
|
||||||
|
status: "unknown",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let statuses: Record<number, ServerStatus> = {};
|
let statuses: Record<number, ServerStatus> = {};
|
||||||
try {
|
try {
|
||||||
@@ -45,9 +67,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
const response = await sshHostApi.post("/db/host", formData, {
|
const response = await sshHostApi.post("/db/host", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
const response = await sshHostApi.post("/db/host", hostData);
|
const response = await sshHostApi.post("/db/host", hostData);
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "create SSH host");
|
throw handleApiError(error, "create SSH host");
|
||||||
@@ -67,9 +91,11 @@ export async function updateSSHHost(
|
|||||||
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
|
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update SSH host");
|
throw handleApiError(error, "update SSH host");
|
||||||
@@ -103,6 +129,7 @@ export async function bulkImportSSHHosts(
|
|||||||
overwrite,
|
overwrite,
|
||||||
...(credentials ? { credentials } : {}),
|
...(credentials ? { credentials } : {}),
|
||||||
});
|
});
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "bulk import SSH hosts");
|
handleApiError(error, "bulk import SSH hosts");
|
||||||
@@ -125,6 +152,7 @@ export async function importSSHConfigHosts(
|
|||||||
content,
|
content,
|
||||||
overwrite,
|
overwrite,
|
||||||
});
|
});
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "import SSH config hosts");
|
handleApiError(error, "import SSH config hosts");
|
||||||
@@ -155,6 +183,7 @@ export async function bulkUpdateSSHHosts(
|
|||||||
hostIds,
|
hostIds,
|
||||||
updates,
|
updates,
|
||||||
});
|
});
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "bulk update SSH hosts");
|
handleApiError(error, "bulk update SSH hosts");
|
||||||
@@ -166,6 +195,7 @@ export async function deleteSSHHost(
|
|||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
const response = await sshHostApi.delete(`/db/host/${hostId}`);
|
||||||
|
invalidateHostsAndStatusCaches();
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "delete SSH host");
|
handleApiError(error, "delete SSH host");
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index";
|
|||||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
import i18n from "@/i18n/i18n";
|
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||||
import {
|
import {
|
||||||
removeSilentSigninFromSearch,
|
removeSilentSigninFromSearch,
|
||||||
shouldTriggerSilentSignin,
|
shouldTriggerSilentSignin,
|
||||||
@@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
const [confirmNewPassword, setConfirmNewPassword] = useState("");
|
||||||
const [resetTempToken, setResetTempToken] = useState("");
|
const [resetTempToken, setResetTempToken] = useState("");
|
||||||
|
|
||||||
const [language, setLanguage] = useState(
|
const [language, setLanguage] = useState(() =>
|
||||||
() => localStorage.getItem("i18nextLng") ?? "en",
|
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleLanguageChange(code: string) {
|
function handleLanguageChange(code: string) {
|
||||||
setLanguage(code);
|
void changeAppLanguage(code)
|
||||||
localStorage.setItem("i18nextLng", code);
|
.then((language) => setLanguage(language))
|
||||||
i18n.changeLanguage(code);
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ function HostStatusCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStatusCheckEnabled(host: Host): boolean {
|
||||||
|
return host.statsConfig?.statusCheckEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
function RecentActivityCard({
|
function RecentActivityCard({
|
||||||
activity,
|
activity,
|
||||||
hosts,
|
hosts,
|
||||||
@@ -797,6 +801,7 @@ function CardItem({
|
|||||||
onAddServiceLink,
|
onAddServiceLink,
|
||||||
onDeleteServiceLink,
|
onDeleteServiceLink,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
slot: CardSlot;
|
slot: CardSlot;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
@@ -826,6 +831,7 @@ function CardItem({
|
|||||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||||
statusLoading?: boolean;
|
statusLoading?: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -922,6 +928,7 @@ function CardItem({
|
|||||||
{slot.id === "network_graph" && (
|
{slot.id === "network_graph" && (
|
||||||
<NetworkGraphCard
|
<NetworkGraphCard
|
||||||
embedded={true}
|
embedded={true}
|
||||||
|
isVisible={isVisible}
|
||||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1051,6 +1058,7 @@ type PanelColumnProps = {
|
|||||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||||
statusLoading: boolean;
|
statusLoading: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PanelColumn({
|
function PanelColumn({
|
||||||
@@ -1082,6 +1090,7 @@ function PanelColumn({
|
|||||||
onAddServiceLink,
|
onAddServiceLink,
|
||||||
onDeleteServiceLink,
|
onDeleteServiceLink,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
|
isVisible = true,
|
||||||
}: PanelColumnProps) {
|
}: PanelColumnProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
||||||
@@ -1138,6 +1147,7 @@ function PanelColumn({
|
|||||||
onAddServiceLink={onAddServiceLink}
|
onAddServiceLink={onAddServiceLink}
|
||||||
onDeleteServiceLink={onDeleteServiceLink}
|
onDeleteServiceLink={onDeleteServiceLink}
|
||||||
statusLoading={statusLoading}
|
statusLoading={statusLoading}
|
||||||
|
isVisible={isVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1188,9 +1198,12 @@ function ColumnDivider({
|
|||||||
export function DashboardTab({
|
export function DashboardTab({
|
||||||
onOpenSingletonTab,
|
onOpenSingletonTab,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
|
isVisible = true,
|
||||||
}: {
|
}: {
|
||||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
onOpenTab: (host: Host, type: TabType) => 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 { t, i18n } = useTranslation();
|
||||||
const { initialLoadComplete } = useServerStatus();
|
const { initialLoadComplete } = useServerStatus();
|
||||||
@@ -1283,6 +1296,7 @@ export function DashboardTab({
|
|||||||
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||||
|
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||||
|
|
||||||
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
||||||
let statuses: Record<number, { status?: string }> = {};
|
let statuses: Record<number, { status?: string }> = {};
|
||||||
@@ -1342,8 +1356,11 @@ export function DashboardTab({
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
const raw = await getSSHHosts().catch(() => []);
|
const raw = await getSSHHosts().catch(() => []);
|
||||||
const mapped = raw.map(sshHostToHost);
|
const mapped = raw.map(sshHostToHost);
|
||||||
|
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||||
if (mounted) setHosts(mapped);
|
if (mounted) setHosts(mapped);
|
||||||
fetchMetrics(mapped).catch(() => {});
|
if (isVisible) {
|
||||||
|
fetchMetrics(statusHosts).catch(() => {});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
|
|
||||||
@@ -1393,28 +1410,37 @@ export function DashboardTab({
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const metricsInterval = setInterval(async () => {
|
const metricsInterval = setInterval(async () => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
const raw = await getSSHHosts().catch(() => []);
|
const raw = await getSSHHosts().catch(() => []);
|
||||||
const mapped = raw.map(sshHostToHost);
|
const mapped = raw.map(sshHostToHost);
|
||||||
|
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||||
if (mounted) setHosts(mapped);
|
if (mounted) setHosts(mapped);
|
||||||
fetchMetrics(mapped).catch(() => {});
|
fetchMetrics(statusHosts).catch(() => {});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
clearInterval(metricsInterval);
|
clearInterval(metricsInterval);
|
||||||
};
|
};
|
||||||
}, [fetchMetrics]);
|
}, [fetchMetrics, isVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewerSessionsRef.current.size === 0) return;
|
if (!isVisible || viewerSessionsRef.current.size === 0) return;
|
||||||
const heartbeat = setInterval(async () => {
|
const heartbeat = setInterval(async () => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
for (const [, sessionId] of viewerSessionsRef.current) {
|
for (const [, sessionId] of viewerSessionsRef.current) {
|
||||||
sendMetricsHeartbeat(sessionId).catch(() => {});
|
sendMetricsHeartbeat(sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
return () => clearInterval(heartbeat);
|
return () => clearInterval(heartbeat);
|
||||||
}, [hostMetrics]);
|
}, [hostMetrics, isVisible]);
|
||||||
|
|
||||||
const handleClearActivity = async () => {
|
const handleClearActivity = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1587,6 +1613,7 @@ export function DashboardTab({
|
|||||||
onAddServiceLink: handleAddServiceLink,
|
onAddServiceLink: handleAddServiceLink,
|
||||||
onDeleteServiceLink: handleDeleteServiceLink,
|
onDeleteServiceLink: handleDeleteServiceLink,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
|
isVisible,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -1707,7 +1734,7 @@ export function DashboardTab({
|
|||||||
)}
|
)}
|
||||||
{slot.id === "host_status" && (
|
{slot.id === "host_status" && (
|
||||||
<HostStatusCard
|
<HostStatusCard
|
||||||
hosts={hosts}
|
hosts={statusCheckHosts}
|
||||||
hostMetrics={hostMetrics}
|
hostMetrics={hostMetrics}
|
||||||
onOpenTab={onOpenTab}
|
onOpenTab={onOpenTab}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -1726,6 +1753,7 @@ export function DashboardTab({
|
|||||||
{slot.id === "network_graph" && (
|
{slot.id === "network_graph" && (
|
||||||
<NetworkGraphCard
|
<NetworkGraphCard
|
||||||
embedded={true}
|
embedded={true}
|
||||||
|
isVisible={isVisible}
|
||||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ interface NetworkGraphCardProps {
|
|||||||
rightSidebarWidth?: number;
|
rightSidebarWidth?: number;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
onOpenInNewTab?: () => void;
|
onOpenInNewTab?: () => void;
|
||||||
|
/** When false, pause status refresh while the surface stays mounted. */
|
||||||
|
isVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||||
@@ -195,6 +197,7 @@ function buildNodeSvg(
|
|||||||
export function NetworkGraphCard({
|
export function NetworkGraphCard({
|
||||||
embedded = true,
|
embedded = true,
|
||||||
onOpenInNewTab,
|
onOpenInNewTab,
|
||||||
|
isVisible = true,
|
||||||
}: NetworkGraphCardProps): React.ReactElement {
|
}: NetworkGraphCardProps): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addTab } = useTabsSafe();
|
const { addTab } = useTabsSafe();
|
||||||
@@ -269,8 +272,19 @@ export function NetworkGraphCard({
|
|||||||
}, [hostMap]);
|
}, [hostMap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isVisible) {
|
||||||
|
if (statusIntervalRef.current) {
|
||||||
|
clearInterval(statusIntervalRef.current);
|
||||||
|
statusIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
statusIntervalRef.current = setInterval(updateHostStatuses, 30000);
|
statusIntervalRef.current = setInterval(() => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
updateHostStatuses();
|
||||||
|
}, 30000);
|
||||||
const onClickOutside = (e: MouseEvent) => {
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
contextMenuRef.current &&
|
contextMenuRef.current &&
|
||||||
@@ -293,7 +307,7 @@ export function NetworkGraphCard({
|
|||||||
document.removeEventListener("mousedown", onClickOutside, true);
|
document.removeEventListener("mousedown", onClickOutside, true);
|
||||||
themeObserver.disconnect();
|
themeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isVisible]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -335,7 +335,10 @@ function DockerManagerInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
pollContainers();
|
pollContainers();
|
||||||
const interval = setInterval(pollContainers, 5000);
|
const interval = setInterval(() => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
void pollContainers();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export function ContainerStats({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
const interval = setInterval(fetchStats, 2000);
|
const interval = setInterval(() => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
void fetchStats();
|
||||||
|
}, 2000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStats]);
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,10 @@ export function LogViewer({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const interval = setInterval(fetchLogs, 3000);
|
const interval = setInterval(() => {
|
||||||
|
if (document.visibilityState === "hidden") return;
|
||||||
|
void fetchLogs();
|
||||||
|
}, 3000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [autoRefresh, fetchLogs]);
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ function FileManagerContent({
|
|||||||
initialPath,
|
initialPath,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenTerminalTab,
|
onOpenTerminalTab,
|
||||||
|
isVisible = true,
|
||||||
}: FileManagerProps) {
|
}: FileManagerProps) {
|
||||||
const { openWindow } = useWindowManager();
|
const { openWindow } = useWindowManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -268,7 +269,7 @@ function FileManagerContent({
|
|||||||
}, [currentHost]);
|
}, [currentHost]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sshSessionId) {
|
if (sshSessionId && isVisible) {
|
||||||
startKeepalive();
|
startKeepalive();
|
||||||
} else {
|
} else {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
@@ -277,7 +278,7 @@ function FileManagerContent({
|
|||||||
return () => {
|
return () => {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
};
|
};
|
||||||
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
|
||||||
|
|
||||||
const initialFileOpenedRef = useRef(false);
|
const initialFileOpenedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3105,6 +3106,7 @@ function FileManagerInner({
|
|||||||
initialPath,
|
initialPath,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenTerminalTab,
|
onOpenTerminalTab,
|
||||||
|
isVisible = true,
|
||||||
}: FileManagerProps) {
|
}: FileManagerProps) {
|
||||||
return (
|
return (
|
||||||
<WindowManager>
|
<WindowManager>
|
||||||
@@ -3114,6 +3116,7 @@ function FileManagerInner({
|
|||||||
initialPath={initialPath}
|
initialPath={initialPath}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onOpenTerminalTab={onOpenTerminalTab}
|
onOpenTerminalTab={onOpenTerminalTab}
|
||||||
|
isVisible={isVisible}
|
||||||
/>
|
/>
|
||||||
</WindowManager>
|
</WindowManager>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* 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 { createPortal } from "react-dom";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
@@ -185,6 +193,12 @@ export function FileManagerGrid({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
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>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
type: "none",
|
type: "none",
|
||||||
@@ -192,6 +206,52 @@ export function FileManagerGrid({
|
|||||||
counter: 0,
|
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(() => {
|
useEffect(() => {
|
||||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||||
if (dragState.type === "internal" && dragState.files.length > 0) {
|
if (dragState.type === "internal" && dragState.files.length > 0) {
|
||||||
@@ -442,6 +502,77 @@ export function FileManagerGrid({
|
|||||||
setSelectionRect({ x, y, width, height });
|
setSelectionRect({ x, y, width, height });
|
||||||
|
|
||||||
if (gridRef.current) {
|
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 =
|
const fileElements =
|
||||||
gridRef.current.querySelectorAll("[data-file-path]");
|
gridRef.current.querySelectorAll("[data-file-path]");
|
||||||
const selectedPaths: string[] = [];
|
const selectedPaths: string[] = [];
|
||||||
@@ -457,13 +588,6 @@ export function FileManagerGrid({
|
|||||||
bottom: elementRect.bottom - containerRect.top,
|
bottom: elementRect.bottom - containerRect.top,
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectionBox = {
|
|
||||||
left: x,
|
|
||||||
top: y,
|
|
||||||
right: x + width,
|
|
||||||
bottom: y + height,
|
|
||||||
};
|
|
||||||
|
|
||||||
const intersects = !(
|
const intersects = !(
|
||||||
relativeElementRect.right < selectionBox.left ||
|
relativeElementRect.right < selectionBox.left ||
|
||||||
relativeElementRect.left > selectionBox.right ||
|
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(
|
const handleMouseUp = useCallback(
|
||||||
@@ -819,89 +952,126 @@ export function FileManagerGrid({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : 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 && (
|
{createIntent && (
|
||||||
<CreateIntentGridItem
|
<div
|
||||||
intent={createIntent}
|
className="grid gap-4"
|
||||||
onConfirm={onConfirmCreate}
|
style={{
|
||||||
onCancel={onCancelCreate}
|
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<CreateIntentGridItem
|
||||||
|
intent={createIntent}
|
||||||
|
onConfirm={onConfirmCreate}
|
||||||
|
onCancel={onCancelCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{files.map((file) => {
|
<div
|
||||||
const isSelected = selectedFiles.some(
|
className="relative w-full"
|
||||||
(f) => f.path === file.path,
|
style={{ height: gridVirtualizer.getTotalSize() }}
|
||||||
);
|
>
|
||||||
|
{gridVirtualizer.getVirtualItems().map((vRow) => {
|
||||||
return (
|
const start = vRow.index * gridCols;
|
||||||
<div
|
const rowFiles = files.slice(start, start + gridCols);
|
||||||
key={file.path}
|
return (
|
||||||
data-file-path={file.path}
|
<div
|
||||||
draggable={true}
|
key={vRow.key}
|
||||||
className={cn(
|
data-index={vRow.index}
|
||||||
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
|
ref={gridVirtualizer.measureElement}
|
||||||
isSelected && "bg-accent-brand/10 border-accent-brand/40",
|
className="absolute top-0 left-0 w-full"
|
||||||
dragState.target?.path === file.path &&
|
style={{
|
||||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
transform: `translateY(${vRow.start}px)`,
|
||||||
dragState.files.some((f) => f.path === file.path) &&
|
}}
|
||||||
"opacity-50",
|
>
|
||||||
)}
|
<div
|
||||||
title={file.name}
|
className="grid gap-4 pb-4"
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
style={{
|
||||||
onContextMenu={(e) => {
|
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||||
e.preventDefault();
|
}}
|
||||||
e.stopPropagation();
|
>
|
||||||
onContextMenu?.(e, file);
|
{rowFiles.map((file) => {
|
||||||
}}
|
const isSelected = selectedFiles.some(
|
||||||
onDragStart={(e) => handleFileDragStart(e, file)}
|
(f) => f.path === file.path,
|
||||||
onDragOver={(e) => handleFileDragOver(e, file)}
|
);
|
||||||
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
return (
|
||||||
onDrop={(e) => handleFileDrop(e, file)}
|
<div
|
||||||
onDragEnd={handleFileDragEnd}
|
key={file.path}
|
||||||
>
|
data-file-path={file.path}
|
||||||
<div className="relative mb-2 pointer-events-none">
|
draggable={true}
|
||||||
{getFileIcon(file, viewMode)}
|
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",
|
||||||
|
dragState.target?.path === file.path &&
|
||||||
|
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||||
|
dragState.files.some(
|
||||||
|
(f) => f.path === file.path,
|
||||||
|
) && "opacity-50",
|
||||||
|
)}
|
||||||
|
title={file.name}
|
||||||
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onContextMenu?.(e, file);
|
||||||
|
}}
|
||||||
|
onDragStart={(e) => handleFileDragStart(e, file)}
|
||||||
|
onDragOver={(e) => handleFileDragOver(e, file)}
|
||||||
|
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
||||||
|
onDrop={(e) => handleFileDrop(e, file)}
|
||||||
|
onDragEnd={handleFileDragEnd}
|
||||||
|
>
|
||||||
|
<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)
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1"
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{file.type === "file" &&
|
||||||
|
file.size !== undefined &&
|
||||||
|
file.size !== null && (
|
||||||
|
<p className="text-[10px] font-medium text-muted-foreground/60 mt-0.5">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{file.type === "link" && file.linkTarget && (
|
||||||
|
<p
|
||||||
|
className="text-[10px] text-accent-brand mt-0.5 truncate w-full text-center"
|
||||||
|
title={file.linkTarget}
|
||||||
|
>
|
||||||
|
→ {file.linkTarget}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="w-full flex flex-col items-center pointer-events-none">
|
})}
|
||||||
{editingFile?.path === file.path ? (
|
</div>
|
||||||
<input
|
|
||||||
ref={editInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editingName}
|
|
||||||
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"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p
|
|
||||||
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1"
|
|
||||||
title={file.name}
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{file.type === "file" &&
|
|
||||||
file.size !== undefined &&
|
|
||||||
file.size !== null && (
|
|
||||||
<p className="text-[10px] font-medium text-muted-foreground/60 mt-0.5">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{file.type === "link" && file.linkTarget && (
|
|
||||||
<p
|
|
||||||
className="text-[10px] text-accent-brand mt-0.5 truncate w-full text-center"
|
|
||||||
title={file.linkTarget}
|
|
||||||
>
|
|
||||||
→ {file.linkTarget}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -952,91 +1122,106 @@ export function FileManagerGrid({
|
|||||||
onCancel={onCancelCreate}
|
onCancel={onCancelCreate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{files.map((file) => {
|
<div
|
||||||
const isSelected = selectedFiles.some(
|
className="relative w-full"
|
||||||
(f) => f.path === file.path,
|
style={{ height: listVirtualizer.getTotalSize() }}
|
||||||
);
|
>
|
||||||
|
{listVirtualizer.getVirtualItems().map((vItem) => {
|
||||||
return (
|
const file = files[vItem.index];
|
||||||
<div
|
if (!file) return null;
|
||||||
key={file.path}
|
const isSelected = selectedFiles.some(
|
||||||
data-file-path={file.path}
|
(f) => f.path === file.path,
|
||||||
draggable={true}
|
);
|
||||||
className={cn(
|
return (
|
||||||
"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",
|
<div
|
||||||
isSelected && "bg-accent-brand/10",
|
key={vItem.key}
|
||||||
dragState.target?.path === file.path &&
|
data-index={vItem.index}
|
||||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
ref={listVirtualizer.measureElement}
|
||||||
dragState.files.some((f) => f.path === file.path) &&
|
className="absolute top-0 left-0 w-full"
|
||||||
"opacity-50",
|
style={{
|
||||||
)}
|
transform: `translateY(${vItem.start}px)`,
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
}}
|
||||||
onContextMenu={(e) => {
|
>
|
||||||
e.preventDefault();
|
<div
|
||||||
e.stopPropagation();
|
data-file-path={file.path}
|
||||||
onContextMenu?.(e, file);
|
draggable={true}
|
||||||
}}
|
className={cn(
|
||||||
onDragStart={(e) => handleFileDragStart(e, file)}
|
"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",
|
||||||
onDragOver={(e) => handleFileDragOver(e, file)}
|
isSelected && "bg-accent-brand/10",
|
||||||
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
dragState.target?.path === file.path &&
|
||||||
onDrop={(e) => handleFileDrop(e, file)}
|
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||||
onDragEnd={handleFileDragEnd}
|
dragState.files.some((f) => f.path === file.path) &&
|
||||||
>
|
"opacity-50",
|
||||||
<div className="flex items-center gap-3 overflow-hidden pointer-events-none">
|
)}
|
||||||
<div className="shrink-0">
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
{getFileIcon(file, viewMode)}
|
onContextMenu={(e) => {
|
||||||
</div>
|
e.preventDefault();
|
||||||
{editingFile?.path === file.path ? (
|
e.stopPropagation();
|
||||||
<input
|
onContextMenu?.(e, file);
|
||||||
ref={editInputRef}
|
}}
|
||||||
type="text"
|
onDragStart={(e) => handleFileDragStart(e, file)}
|
||||||
value={editingName}
|
onDragOver={(e) => handleFileDragOver(e, file)}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onDragLeave={(e) => handleFileDragLeave(e, file)}
|
||||||
onKeyDown={handleEditKeyDown}
|
onDrop={(e) => handleFileDrop(e, file)}
|
||||||
onBlur={handleEditConfirm}
|
onDragEnd={handleFileDragEnd}
|
||||||
className="flex-1 min-w-0 max-w-[200px] 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 pointer-events-auto"
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="flex items-center gap-3 overflow-hidden pointer-events-none">
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
<div className="shrink-0">
|
||||||
/>
|
{getFileIcon(file, viewMode)}
|
||||||
) : (
|
</div>
|
||||||
<span
|
{editingFile?.path === file.path ? (
|
||||||
className="font-bold truncate tracking-tight"
|
<input
|
||||||
title={file.name}
|
ref={editInputRef}
|
||||||
>
|
type="text"
|
||||||
{file.name}
|
value={editingName}
|
||||||
{file.type === "link" && file.linkTarget && (
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
<span className="text-accent-brand ml-1 normal-case font-normal">
|
onKeyDown={handleEditKeyDown}
|
||||||
→ {file.linkTarget}
|
onBlur={handleEditConfirm}
|
||||||
|
className="flex-1 min-w-0 max-w-[200px] 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 pointer-events-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-bold truncate tracking-tight"
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
{file.type === "link" && file.linkTarget && (
|
||||||
|
<span className="text-accent-brand ml-1 normal-case font-normal">
|
||||||
|
→ {file.linkTarget}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-[10px] text-muted-foreground pointer-events-none">
|
||||||
|
{file.modified || "—"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate hidden md:block pointer-events-none">
|
||||||
|
{file.owner
|
||||||
|
? `${file.owner}${file.group ? `:${file.group}` : ""}`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-[10px] text-right text-muted-foreground tabular-nums pointer-events-none">
|
||||||
|
{file.type === "file" &&
|
||||||
|
file.size !== undefined &&
|
||||||
|
file.size !== null
|
||||||
|
? formatFileSize(file.size)
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="text-[10px] text-right font-mono text-muted-foreground/60 pointer-events-none">
|
||||||
|
{file.permissions || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<span className="text-[10px] text-muted-foreground pointer-events-none">
|
})}
|
||||||
{file.modified || "—"}
|
</div>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-[10px] text-muted-foreground truncate hidden md:block pointer-events-none">
|
|
||||||
{file.owner
|
|
||||||
? `${file.owner}${file.group ? `:${file.group}` : ""}`
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-[10px] text-right text-muted-foreground tabular-nums pointer-events-none">
|
|
||||||
{file.type === "file" &&
|
|
||||||
file.size !== undefined &&
|
|
||||||
file.size !== null
|
|
||||||
? formatFileSize(file.size)
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-[10px] text-right font-mono text-muted-foreground/60 pointer-events-none">
|
|
||||||
{file.permissions || "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
start();
|
||||||
|
};
|
||||||
|
|
||||||
void reconcileTransfers();
|
void reconcileTransfers();
|
||||||
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
if (document.visibilityState !== "hidden") start();
|
||||||
return () => clearInterval(interval);
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
}, [t, formatTransferMetrics]);
|
}, [t, formatTransferMetrics]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export function FileViewer({
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-background">
|
<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-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="flex items-center gap-3">
|
||||||
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
|
||||||
{fileTypeInfo.icon}
|
{fileTypeInfo.icon}
|
||||||
@@ -434,7 +434,7 @@ export function FileViewer({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export interface FileManagerProps {
|
|||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onOpenTerminalTab?: (path?: string) => 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">;
|
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 { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||||
import { getBasePath } from "@/lib/base-path.ts";
|
import { getBasePath } from "@/lib/base-path.ts";
|
||||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||||
|
import {
|
||||||
|
isFirefoxBrowser,
|
||||||
|
isPasteShortcut,
|
||||||
|
pasteTextToRemote,
|
||||||
|
} from "./guacamole-clipboard.ts";
|
||||||
|
|
||||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||||
|
|
||||||
@@ -338,6 +343,32 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
displayElement.setAttribute("tabindex", "0");
|
displayElement.setAttribute("tabindex", "0");
|
||||||
displayElement.style.outline = "none";
|
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 = () => {
|
display.onresize = () => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
rescaleDisplay(true);
|
rescaleDisplay(true);
|
||||||
@@ -576,7 +607,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
const syncClipboard = useCallback(() => {
|
const syncClipboard = useCallback(() => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client || !navigator.clipboard?.readText) return;
|
if (!client || isFirefoxBrowser() || !navigator.clipboard?.readText) return;
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.readText()
|
.readText()
|
||||||
.then((text) => {
|
.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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user