mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d0658e41 | |||
| 1aea3603a7 | |||
| 972e27eb64 | |||
| 38e128bd16 | |||
| 0712fdd731 | |||
| bb5559c696 | |||
| b2ff35e106 | |||
| 19a2cb8eed | |||
| 078e6d5de0 | |||
| 794714368a | |||
| 4fbaf50e08 | |||
| 4ec4cfcdb7 | |||
| a46f2f1343 | |||
| 72e5ff5a64 | |||
| 63cfdfda9e | |||
| 3a3b51d1ae | |||
| 3f4280c2ff | |||
| 97124bc3b6 | |||
| 253be0077e | |||
| fe96e68872 | |||
| 30acbed1a7 | |||
| 7416734f68 |
@@ -14,10 +14,6 @@ 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:
|
||||||
@@ -33,11 +29,6 @@ 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:
|
||||||
@@ -46,12 +37,8 @@ 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:
|
||||||
@@ -111,7 +98,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=${{ env.SOURCE_SHA }}
|
org.opencontainers.image.revision=${{ github.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
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
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,10 +23,6 @@ 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:
|
||||||
@@ -42,11 +38,6 @@ 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)"
|
||||||
@@ -63,7 +54,6 @@ 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
|
||||||
@@ -154,7 +144,6 @@ 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
|
||||||
@@ -365,7 +354,6 @@ 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
|
||||||
@@ -592,7 +580,6 @@ 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
|
||||||
@@ -699,7 +686,6 @@ 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
|
||||||
@@ -837,7 +823,6 @@ 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
|
||||||
@@ -948,7 +933,6 @@ 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_SHA" >> "$GITHUB_OUTPUT"
|
echo "build_ref=main" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: [prep, merge-to-main]
|
needs: [prep, merge-to-main]
|
||||||
@@ -287,7 +287,6 @@ 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:
|
||||||
@@ -352,17 +351,15 @@ 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, merge-to-main, electron-release]
|
needs: [prep, 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,12 +31,14 @@
|
|||||||
<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" />
|
||||||
@@ -266,8 +268,6 @@ 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,8 +279,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "4822:4822"
|
- "4822:4822"
|
||||||
volumes:
|
|
||||||
- termix-data:/termix-data
|
|
||||||
networks:
|
networks:
|
||||||
- termix-net
|
- termix-net
|
||||||
|
|
||||||
@@ -293,13 +291,6 @@ 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:26-slim AS deps
|
FROM node:24-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:26-slim AS production-deps
|
FROM node:24-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:26-slim
|
FROM node:24-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
ENV DATA_DIR=/app/data \
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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:
|
||||||
@@ -23,8 +21,6 @@ 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,7 +10,6 @@ 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:
|
||||||
@@ -20,8 +19,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+4
-16
@@ -235,18 +235,6 @@ 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;
|
||||||
@@ -445,8 +433,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 $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
@@ -688,8 +676,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 $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $http_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 $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $http_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 $proxy_x_forwarded_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $http_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, "localhost", async () => {
|
server.listen(callbackPort, "127.0.0.1", async () => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(authUrl);
|
await shell.openExternal(authUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Generated
+570
-603
File diff suppressed because it is too large
Load Diff
+30
-31
@@ -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 && node scripts/patch-xterm-android-ime.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",
|
||||||
"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,9 +45,8 @@
|
|||||||
"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.1",
|
"axios": "^1.18.0",
|
||||||
"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",
|
||||||
@@ -59,28 +58,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.2.1",
|
"js-yaml": "^5.0.0",
|
||||||
"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.42.2",
|
"motion": "^12.38.0",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"nanoid": "^5.1.16",
|
"nanoid": "^5.1.15",
|
||||||
"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.7.0",
|
"undici": "^8.5.0",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.2",
|
"@biomejs/biome": "2.5.1",
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.4",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@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.5",
|
"@codemirror/view": "^6.43.1",
|
||||||
"@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",
|
||||||
@@ -92,23 +91,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.15",
|
"@radix-ui/react-accordion": "^1.2.13",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.18",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-checkbox": "^1.3.6",
|
"@radix-ui/react-checkbox": "^1.3.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.18",
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||||
"@radix-ui/react-label": "^2.1.11",
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
"@radix-ui/react-popover": "^1.1.18",
|
"@radix-ui/react-popover": "^1.1.16",
|
||||||
"@radix-ui/react-progress": "^1.1.11",
|
"@radix-ui/react-progress": "^1.1.9",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||||
"@radix-ui/react-select": "^2.3.2",
|
"@radix-ui/react-select": "^2.3.1",
|
||||||
"@radix-ui/react-separator": "^1.1.11",
|
"@radix-ui/react-separator": "^1.1.9",
|
||||||
"@radix-ui/react-slider": "^1.4.2",
|
"@radix-ui/react-slider": "^1.4.1",
|
||||||
"@radix-ui/react-slot": "^1.3.0",
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
"@radix-ui/react-switch": "^1.3.2",
|
"@radix-ui/react-switch": "^1.3.1",
|
||||||
"@radix-ui/react-tabs": "^1.1.16",
|
"@radix-ui/react-tabs": "^1.1.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.11",
|
"@radix-ui/react-tooltip": "^1.2.9",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@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",
|
||||||
@@ -131,7 +130,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.3",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"@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",
|
||||||
@@ -144,7 +143,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": "^43.0.0",
|
"electron": "^42.4.1",
|
||||||
"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",
|
||||||
@@ -153,13 +152,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.4",
|
"i18next": "^26.3.1",
|
||||||
"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.1",
|
"radix-ui": "^1.6.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-cytoscapejs": "^2.0.0",
|
"react-cytoscapejs": "^2.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -173,7 +172,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.3",
|
"sharp": "^0.35.2",
|
||||||
"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,31 +26,10 @@ 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: protocol version negotiation.
|
// Patch 1: version acceptance list
|
||||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
|
||||||
// versions Termix can handle, and conservatively answer future 1.x versions as
|
const newVersionCheck =
|
||||||
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
|
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
|
||||||
// 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') {";
|
||||||
@@ -126,23 +105,17 @@ const newReadyHandler =
|
|||||||
|
|
||||||
let patched = false;
|
let patched = false;
|
||||||
|
|
||||||
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
|
if (!guacdClientContent.includes(newVersionCheck)) {
|
||||||
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
|
if (!guacdClientContent.includes(oldVersionCheck)) {
|
||||||
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,29 +1,6 @@
|
|||||||
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, vi } from "vitest";
|
import { describe, expect, it } 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", () => {
|
||||||
@@ -43,49 +20,4 @@ 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],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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,8 +460,6 @@ 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,
|
||||||
@@ -566,30 +564,12 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timeoutRow = sqlite
|
sqlite.prepare("DELETE FROM user_open_tabs").run();
|
||||||
.prepare(
|
databaseLogger.info("Open tabs cleared on startup", {
|
||||||
"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",
|
operation: "db_init_open_tabs_cleanup",
|
||||||
count: result.changes,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not clear expired open tabs on startup", {
|
databaseLogger.warn("Could not clear open tabs on startup", {
|
||||||
operation: "db_init_open_tabs_cleanup_failed",
|
operation: "db_init_open_tabs_cleanup_failed",
|
||||||
error: e,
|
error: e,
|
||||||
});
|
});
|
||||||
@@ -715,17 +695,6 @@ 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");
|
||||||
@@ -778,10 +747,6 @@ 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,
|
||||||
@@ -1588,8 +1553,6 @@ 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,9 +54,6 @@ 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`),
|
||||||
@@ -660,8 +657,6 @@ 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 { execFileSync } from "child_process";
|
import { execSync } 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 {
|
||||||
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
|
execSync(`openssl x509 -in "${certFile}" -noout 2>/dev/null`, {
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,9 +47,8 @@ function getCertInfo(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const endDateRaw = execFileSync(
|
const endDateRaw = execSync(
|
||||||
"openssl",
|
`openssl x509 -in "${certFile}" -noout -enddate`,
|
||||||
["x509", "-in", certFile, "-noout", "-enddate"],
|
|
||||||
{ stdio: "pipe" },
|
{ stdio: "pipe" },
|
||||||
)
|
)
|
||||||
.toString()
|
.toString()
|
||||||
@@ -58,25 +57,17 @@ function getCertInfo(): {
|
|||||||
const expiresAt = new Date(endDateRaw).toISOString();
|
const expiresAt = new Date(endDateRaw).toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
execSync(`openssl x509 -in "${certFile}" -checkend 0 -noout`, {
|
||||||
"openssl",
|
|
||||||
["x509", "-in", certFile, "-checkend", "0", "-noout"],
|
|
||||||
{
|
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "expired", expiresAt };
|
return { status: "expired", expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
execSync(`openssl x509 -in "${certFile}" -checkend 2592000 -noout`, {
|
||||||
"openssl",
|
|
||||||
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
|
|
||||||
{
|
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
},
|
});
|
||||||
);
|
|
||||||
return { status: "valid", expiresAt };
|
return { status: "valid", expiresAt };
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "expiring", expiresAt };
|
return { status: "expiring", expiresAt };
|
||||||
@@ -274,7 +265,7 @@ export function registerAcmeSSLRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync("certbot", ["--version"], { stdio: "pipe" });
|
execSync("certbot --version", { stdio: "pipe" });
|
||||||
} catch {
|
} catch {
|
||||||
return res
|
return res
|
||||||
.status(500)
|
.status(500)
|
||||||
@@ -287,16 +278,13 @@ 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 certbotDirArgs = [
|
const certbotDirFlags = [
|
||||||
"--config-dir",
|
`--config-dir "${CERTBOT_CONFIG_DIR}"`,
|
||||||
CERTBOT_CONFIG_DIR,
|
`--work-dir "${CERTBOT_WORK_DIR}"`,
|
||||||
"--work-dir",
|
`--logs-dir "${CERTBOT_LOGS_DIR}"`,
|
||||||
CERTBOT_WORK_DIR,
|
].join(" ");
|
||||||
"--logs-dir",
|
|
||||||
CERTBOT_LOGS_DIR,
|
|
||||||
];
|
|
||||||
|
|
||||||
let certbotArgs: string[];
|
let certbotCmd: string;
|
||||||
|
|
||||||
if (challengeType === "dns-cloudflare") {
|
if (challengeType === "dns-cloudflare") {
|
||||||
if (!cloudflareToken) {
|
if (!cloudflareToken) {
|
||||||
@@ -314,39 +302,40 @@ export function registerAcmeSSLRoutes(
|
|||||||
{ mode: 0o600 },
|
{ mode: 0o600 },
|
||||||
);
|
);
|
||||||
|
|
||||||
certbotArgs = [
|
certbotCmd = [
|
||||||
|
"certbot",
|
||||||
"certonly",
|
"certonly",
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"--agree-tos",
|
"--agree-tos",
|
||||||
"--dns-cloudflare",
|
"--dns-cloudflare",
|
||||||
"--dns-cloudflare-credentials",
|
`--dns-cloudflare-credentials "${CLOUDFLARE_CREDENTIALS_FILE}"`,
|
||||||
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",
|
||||||
...certbotDirArgs,
|
certbotDirFlags,
|
||||||
];
|
].join(" ");
|
||||||
} else {
|
} else {
|
||||||
certbotArgs = [
|
certbotCmd = [
|
||||||
|
"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",
|
||||||
...certbotDirArgs,
|
certbotDirFlags,
|
||||||
];
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info("Requesting Let's Encrypt certificate", {
|
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||||
@@ -355,10 +344,7 @@ export function registerAcmeSSLRoutes(
|
|||||||
operation: "acme_cert_request",
|
operation: "acme_cert_request",
|
||||||
});
|
});
|
||||||
|
|
||||||
execFileSync("certbot", certbotArgs, {
|
execSync(certbotCmd, { stdio: "pipe", timeout: 120000 });
|
||||||
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 { and, eq } from "drizzle-orm";
|
import { 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,10 +87,6 @@ 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`,
|
||||||
@@ -196,10 +192,6 @@ 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) => {
|
||||||
@@ -440,12 +432,7 @@ export function registerCredentialDeployRoutes(
|
|||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshCredentials)
|
.from(sshCredentials)
|
||||||
.where(
|
.where(eq(sshCredentials.id, credentialId))
|
||||||
and(
|
|
||||||
eq(sshCredentials.id, credentialId),
|
|
||||||
eq(sshCredentials.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1),
|
.limit(1),
|
||||||
"ssh_credentials",
|
"ssh_credentials",
|
||||||
userId,
|
userId,
|
||||||
@@ -475,11 +462,7 @@ export function registerCredentialDeployRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const targetHost = await SimpleDBOps.select(
|
const targetHost = await SimpleDBOps.select(
|
||||||
db
|
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
|
||||||
.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, DatabaseSaveTrigger } from "../db/index.js";
|
import { db } 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,7 +107,6 @@ 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);
|
||||||
@@ -176,7 +175,6 @@ 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);
|
||||||
@@ -274,7 +272,6 @@ 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,8 +2,6 @@ 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();
|
||||||
@@ -17,82 +15,10 @@ const proxyCache = new Map<string, ProxyCacheEntry>();
|
|||||||
const CACHE_SIZE = 50;
|
const CACHE_SIZE = 50;
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
const blockedAddresses = new BlockList();
|
function fetchJson(url: string): Promise<unknown> {
|
||||||
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.protocol === "https:" ? https : http;
|
const mod = url.startsWith("https") ? https : http;
|
||||||
const req = mod.get(
|
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
|
||||||
{
|
|
||||||
protocol: url.protocol,
|
|
||||||
hostname: address,
|
|
||||||
port: url.port || undefined,
|
|
||||||
path: `${url.pathname}${url.search}`,
|
|
||||||
headers: { Host: url.host },
|
|
||||||
servername: url.protocol === "https:" ? url.hostname : undefined,
|
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
@@ -104,8 +30,7 @@ async function fetchJson(rawUrl: string): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.on("error", reject);
|
res.on("error", reject);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
req.on("error", reject);
|
req.on("error", reject);
|
||||||
req.on("timeout", () => {
|
req.on("timeout", () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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,10 +25,7 @@ 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 {
|
import { pickResolvedUsername } from "../../ssh/credential-username.js";
|
||||||
pickResolvedPassword,
|
|
||||||
pickResolvedUsername,
|
|
||||||
} from "../../ssh/credential-username.js";
|
|
||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
@@ -43,10 +40,6 @@ 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();
|
||||||
@@ -168,10 +161,9 @@ registerHostInternalRoutes(router);
|
|||||||
* description: Failed to save SSH data.
|
* description: Failed to save SSH data.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
["/db/host", "/enroll"],
|
"/db/host",
|
||||||
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;
|
||||||
@@ -204,10 +196,6 @@ router.post(
|
|||||||
hostData = req.body;
|
hostData = req.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.path === "/enroll") {
|
|
||||||
hostData = applyHostEnrollmentDefaults(hostData);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
connectionType,
|
connectionType,
|
||||||
name,
|
name,
|
||||||
@@ -271,8 +259,6 @@ router.post(
|
|||||||
rdpPort,
|
rdpPort,
|
||||||
vncPort,
|
vncPort,
|
||||||
telnetPort,
|
telnetPort,
|
||||||
rdpAuthType,
|
|
||||||
rdpCredentialId,
|
|
||||||
rdpUser,
|
rdpUser,
|
||||||
rdpPassword,
|
rdpPassword,
|
||||||
rdpDomain,
|
rdpDomain,
|
||||||
@@ -282,8 +268,6 @@ router.post(
|
|||||||
vncCredentialId,
|
vncCredentialId,
|
||||||
vncPassword,
|
vncPassword,
|
||||||
vncUser,
|
vncUser,
|
||||||
telnetAuthType,
|
|
||||||
telnetCredentialId,
|
|
||||||
telnetUser,
|
telnetUser,
|
||||||
telnetPassword,
|
telnetPassword,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -401,11 +385,6 @@ 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,
|
||||||
@@ -416,11 +395,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -459,12 +433,7 @@ 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 = password || null;
|
sshDataObj.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;
|
||||||
@@ -551,67 +520,6 @@ 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:
|
||||||
@@ -734,7 +642,7 @@ router.post(
|
|||||||
|
|
||||||
const cred = credentials[0];
|
const cred = credentials[0];
|
||||||
|
|
||||||
resolvedPassword = pickResolvedPassword(password, cred.password);
|
resolvedPassword = cred.password as string | undefined;
|
||||||
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;
|
||||||
@@ -927,8 +835,6 @@ router.put(
|
|||||||
rdpPort,
|
rdpPort,
|
||||||
vncPort,
|
vncPort,
|
||||||
telnetPort,
|
telnetPort,
|
||||||
rdpAuthType,
|
|
||||||
rdpCredentialId,
|
|
||||||
rdpUser,
|
rdpUser,
|
||||||
rdpPassword,
|
rdpPassword,
|
||||||
rdpDomain,
|
rdpDomain,
|
||||||
@@ -938,8 +844,6 @@ router.put(
|
|||||||
vncCredentialId,
|
vncCredentialId,
|
||||||
vncPassword,
|
vncPassword,
|
||||||
vncUser,
|
vncUser,
|
||||||
telnetAuthType,
|
|
||||||
telnetCredentialId,
|
|
||||||
telnetUser,
|
telnetUser,
|
||||||
telnetPassword,
|
telnetPassword,
|
||||||
} = hostData;
|
} = hostData;
|
||||||
@@ -1054,11 +958,6 @@ 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,
|
||||||
@@ -1069,11 +968,6 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1121,12 +1015,7 @@ router.put(
|
|||||||
if (keyType) {
|
if (keyType) {
|
||||||
sshDataObj.keyType = keyType;
|
sshDataObj.keyType = keyType;
|
||||||
}
|
}
|
||||||
sshDataObj.password = password || null;
|
sshDataObj.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;
|
||||||
@@ -1176,7 +1065,6 @@ 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)
|
||||||
@@ -1227,20 +1115,12 @@ 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 ||
|
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
|
||||||
newRdpCredId !== null ||
|
|
||||||
newVncCredId !== null ||
|
|
||||||
newTelnetCredId !== null;
|
|
||||||
if (hadCredential && !willHaveCredential) {
|
if (hadCredential && !willHaveCredential) {
|
||||||
await db
|
await db
|
||||||
.delete(hostAccess)
|
.delete(hostAccess)
|
||||||
@@ -1428,7 +1308,6 @@ 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,
|
||||||
@@ -1439,8 +1318,6 @@ 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,
|
||||||
|
|
||||||
@@ -1783,8 +1660,6 @@ 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,
|
||||||
@@ -1794,8 +1669,6 @@ 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
|
||||||
@@ -2572,7 +2445,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: pickResolvedPassword(host.password, credential.password),
|
password: credential.password,
|
||||||
key: credential.key,
|
key: credential.key,
|
||||||
keyPassword: credential.keyPassword,
|
keyPassword: credential.keyPassword,
|
||||||
keyType: credential.keyType,
|
keyType: credential.keyType,
|
||||||
|
|||||||
@@ -362,9 +362,7 @@ router.delete(
|
|||||||
return res.status(403).json({ error: "Not host owner" });
|
return res.status(403).json({ error: "Not host owner" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
|
||||||
.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,
|
||||||
@@ -1371,14 +1369,7 @@ router.delete(
|
|||||||
return res.status(403).json({ error: "Not snippet owner" });
|
return res.status(403).json({ error: "Not snippet owner" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId));
|
||||||
.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,58 +2,24 @@ 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, desc, lt } from "drizzle-orm";
|
import { eq, and, desc, lt } from "drizzle-orm";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import { sessionRecordings, hosts, users } from "../db/schema.js";
|
import { sessionRecordings, hosts } 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";
|
||||||
|
|
||||||
const permissionManager = PermissionManager.getInstance();
|
// Delete session log files and DB rows older than this many days
|
||||||
|
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() - getRetentionDays() * 24 * 60 * 60 * 1000,
|
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||||
).toISOString();
|
).toISOString();
|
||||||
|
|
||||||
const old = await db
|
const old = await db
|
||||||
@@ -67,7 +33,8 @@ 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);
|
||||||
if (isAllowedRecordingPath(resolved) && fs.existsSync(resolved)) {
|
const allowed = path.resolve(DATA_DIR, "session_logs");
|
||||||
|
if (resolved.startsWith(allowed) && fs.existsSync(resolved)) {
|
||||||
await fs.promises.unlink(resolved).catch(() => {});
|
await fs.promises.unlink(resolved).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +81,6 @@ 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,
|
||||||
@@ -124,16 +90,12 @@ 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))
|
||||||
.leftJoin(users, eq(sessionRecordings.userId, users.id))
|
.where(eq(sessionRecordings.userId, userId))
|
||||||
.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) => {
|
||||||
@@ -158,46 +120,6 @@ 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}:
|
||||||
@@ -231,13 +153,12 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.id, id))
|
.where(
|
||||||
|
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, {
|
||||||
@@ -285,27 +206,26 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||||
recordingPath: sessionRecordings.recordingPath,
|
|
||||||
userId: sessionRecordings.userId,
|
|
||||||
format: sessionRecordings.format,
|
|
||||||
})
|
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.id, id))
|
.where(
|
||||||
|
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);
|
||||||
if (!isAllowedRecordingPath(resolvedPath)) {
|
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
||||||
|
if (!resolvedPath.startsWith(allowedBase)) {
|
||||||
return res.status(403).json({ error: "Forbidden" });
|
return res.status(403).json({ error: "Forbidden" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,14 +233,8 @@ 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);
|
const content = await fs.promises.readFile(resolvedPath, "utf-8");
|
||||||
const contentType =
|
res.type("text/plain").send(content);
|
||||||
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",
|
||||||
@@ -363,26 +277,27 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({ recordingPath: sessionRecordings.recordingPath })
|
||||||
recordingPath: sessionRecordings.recordingPath,
|
|
||||||
userId: sessionRecordings.userId,
|
|
||||||
})
|
|
||||||
.from(sessionRecordings)
|
.from(sessionRecordings)
|
||||||
.where(eq(sessionRecordings.id, id))
|
.where(
|
||||||
|
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.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
|
await db
|
||||||
|
.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);
|
||||||
if (isAllowedRecordingPath(resolvedPath) && fs.existsSync(resolvedPath)) {
|
const allowedBase = path.resolve(DATA_DIR, "session_logs");
|
||||||
|
if (resolvedPath.startsWith(allowedBase) && fs.existsSync(resolvedPath)) {
|
||||||
await fs.promises.unlink(resolvedPath).catch(() => {});
|
await fs.promises.unlink(resolvedPath).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,8 @@ vi.mock("../../utils/logger.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
|
||||||
isOIDCUserAllowed,
|
await import("./user-oidc-utils.js");
|
||||||
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", () => {
|
||||||
@@ -206,48 +199,3 @@ 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,13 +6,6 @@ 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;
|
||||||
@@ -424,102 +417,3 @@ 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("does not accept the account password as a second factor", async () => {
|
it("accepts the correct password", async () => {
|
||||||
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(false);
|
expect(await verifyTotpReauth(makeUser(), "correct-horse")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a valid TOTP code without a password", async () => {
|
it("accepts a valid TOTP code without a password", async () => {
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ 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(
|
||||||
@@ -332,6 +342,14 @@ 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) {
|
||||||
@@ -340,22 +358,6 @@ 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" });
|
||||||
}
|
}
|
||||||
@@ -363,7 +365,7 @@ export function registerUserTotpRoutes(
|
|||||||
const userDataKey = authManager.getUserDataKey(userId);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
const verified = await verifyTotpReauth(
|
const verified = await verifyTotpReauth(
|
||||||
userRecord,
|
userRecord,
|
||||||
totp_code,
|
credential,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
@@ -426,6 +428,14 @@ 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) {
|
||||||
@@ -434,22 +444,6 @@ 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" });
|
||||||
}
|
}
|
||||||
@@ -457,7 +451,7 @@ export function registerUserTotpRoutes(
|
|||||||
const userDataKey = authManager.getUserDataKey(userId);
|
const userDataKey = authManager.getUserDataKey(userId);
|
||||||
const verified = await verifyTotpReauth(
|
const verified = await verifyTotpReauth(
|
||||||
userRecord,
|
userRecord,
|
||||||
totp_code,
|
credential,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
if (!verified) {
|
if (!verified) {
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ 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,
|
||||||
@@ -30,8 +26,6 @@ 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";
|
||||||
@@ -658,10 +652,7 @@ 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 = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
|
||||||
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 {
|
||||||
@@ -1008,7 +999,9 @@ 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 = isOidcTokenCallback(frontendOrigin);
|
const ghIsTokenCallback =
|
||||||
|
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
|
||||||
@@ -1115,20 +1108,25 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1466,16 +1464,10 @@ 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", {
|
||||||
@@ -1487,7 +1479,9 @@ 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 = isOidcTokenCallback(frontendOrigin);
|
const isTokenCallback =
|
||||||
|
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"
|
||||||
@@ -1794,84 +1788,6 @@ 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,10 +3,6 @@ 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();
|
||||||
|
|
||||||
@@ -25,63 +21,6 @@ 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,
|
||||||
@@ -150,31 +89,35 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
clientOptions,
|
clientOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
server.on("open", (clientConnection: GuacamoleClientConnection) => {
|
server.on(
|
||||||
|
"open",
|
||||||
|
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||||
guacLogger.info("Guacamole connection opened", {
|
guacLogger.info("Guacamole connection opened", {
|
||||||
operation: "guac_connection_open",
|
operation: "guac_connection_open",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
type: clientConnection.connectionSettings?.type,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
server.on(
|
||||||
|
"close",
|
||||||
|
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||||
guacLogger.info("Guacamole connection closed", {
|
guacLogger.info("Guacamole connection closed", {
|
||||||
operation: "guac_connection_close",
|
operation: "guac_connection_close",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
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?.connection?.type,
|
type: clientConnection.connectionSettings?.type,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ 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());
|
||||||
|
|
||||||
@@ -529,28 +526,6 @@ 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":
|
||||||
@@ -558,11 +533,7 @@ router.post(
|
|||||||
guacConfig["drive-path"] = "/drive";
|
guacConfig["drive-path"] = "/drive";
|
||||||
guacConfig["create-drive-path"] = true;
|
guacConfig["create-drive-path"] = true;
|
||||||
}
|
}
|
||||||
token = tokenService.createRdpToken(
|
token = tokenService.createRdpToken(hostname, username, password, {
|
||||||
hostname,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
{
|
|
||||||
port,
|
port,
|
||||||
domain,
|
domain,
|
||||||
security:
|
security:
|
||||||
@@ -577,9 +548,7 @@ router.post(
|
|||||||
: true,
|
: true,
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
});
|
||||||
recordingMetadata,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
token = tokenService.createVncToken(
|
token = tokenService.createVncToken(
|
||||||
@@ -592,21 +561,14 @@ router.post(
|
|||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
token = tokenService.createTelnetToken(
|
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||||
hostname,
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
{
|
|
||||||
port,
|
port,
|
||||||
...guacConfig,
|
...guacConfig,
|
||||||
...guacdOverrides,
|
...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,23 +45,4 @@ 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,15 +30,6 @@ 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";
|
||||||
@@ -136,7 +127,6 @@ 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 = {
|
||||||
@@ -154,7 +144,6 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -167,7 +156,6 @@ 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 = {
|
||||||
@@ -183,7 +171,6 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -196,7 +183,6 @@ 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 = {
|
||||||
@@ -212,7 +198,6 @@ export class GuacamoleTokenService {
|
|||||||
...settingsOptions,
|
...settingsOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -31,27 +30,6 @@ 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,19 +21,6 @@ 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,7 +14,6 @@ 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;
|
||||||
|
|
||||||
@@ -277,10 +276,6 @@ 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);
|
||||||
@@ -494,10 +489,6 @@ 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,7 +9,6 @@ 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;
|
||||||
@@ -47,10 +46,6 @@ 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
|
||||||
@@ -94,7 +89,7 @@ export function registerDockerContainerRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -194,7 +189,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -292,7 +287,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -392,7 +387,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -492,7 +487,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -592,7 +587,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -692,7 +687,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -797,7 +792,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -930,7 +925,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -1045,7 +1040,7 @@ export function registerDockerContainerRoutes(
|
|||||||
return res.status(401).json({ error: "Authentication required" });
|
return res.status(401).json({ error: "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = getOwnedSession(sessionId, userId);
|
const session = sshSessions[sessionId];
|
||||||
|
|
||||||
if (!session || !session.isConnected) {
|
if (!session || !session.isConnected) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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;
|
||||||
|
|
||||||
@@ -1618,7 +1617,6 @@ 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) {
|
||||||
@@ -2138,19 +2136,12 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId) {
|
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||||
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 });
|
||||||
});
|
});
|
||||||
@@ -2200,10 +2191,6 @@ 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++;
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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"`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 { buildDeleteCommand } from "./file-manager-operation-commands.js";
|
import { isWindowsSftpPath } from "./transfer-paths.js";
|
||||||
|
|
||||||
type FileOperationRoutesDeps = {
|
type FileOperationRoutesDeps = {
|
||||||
sshSessions: Record<string, SSHSession>;
|
sshSessions: Record<string, SSHSession>;
|
||||||
@@ -375,10 +375,22 @@ export function registerFileOperationRoutes(
|
|||||||
});
|
});
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
|
|
||||||
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
|
const isWindowsPath = isWindowsSftpPath(itemPath);
|
||||||
itemPath,
|
let deleteCommand: string;
|
||||||
Boolean(isDirectory),
|
if (isWindowsPath) {
|
||||||
);
|
const winPath = itemPath
|
||||||
|
.replace(/\//g, "\\")
|
||||||
|
.replace(/^\\([A-Za-z]:\\)/, "$1");
|
||||||
|
const escapedWinPath = winPath.replace(/"/g, '""');
|
||||||
|
deleteCommand = isDirectory
|
||||||
|
? `rd /s /q "${escapedWinPath}"`
|
||||||
|
: `del /f /q "${escapedWinPath}"`;
|
||||||
|
} else {
|
||||||
|
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||||
|
deleteCommand = isDirectory
|
||||||
|
? `rm -rf '${escapedPath}'`
|
||||||
|
: `rm -f '${escapedPath}'`;
|
||||||
|
}
|
||||||
|
|
||||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -409,7 +421,10 @@ export function registerFileOperationRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
execChannel(sshConn, commandWithSuccess, (err, stream) => {
|
execChannel(
|
||||||
|
sshConn,
|
||||||
|
`${deleteCommand} && echo "SUCCESS"`,
|
||||||
|
(err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
fileLogger.error("SSH deleteItem error:", err);
|
fileLogger.error("SSH deleteItem error:", err);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -487,7 +502,8 @@ export function registerFileOperationRoutes(
|
|||||||
.json({ error: `Stream error: ${streamErr.message}` });
|
.json({ error: `Stream error: ${streamErr.message}` });
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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";
|
||||||
@@ -195,7 +194,6 @@ 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;
|
||||||
|
|
||||||
@@ -1761,7 +1759,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await resolveSshConnectConfigHost(config);
|
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { Client } from "ssh2";
|
import { describe, it, expect } from "vitest";
|
||||||
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", () => {
|
||||||
@@ -68,57 +66,3 @@ 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,5 +1,4 @@
|
|||||||
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;
|
||||||
@@ -22,32 +21,6 @@ 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,
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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,88 +250,6 @@ 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();
|
||||||
|
|||||||
+30
-195
@@ -4,10 +4,7 @@ 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 {
|
import { pickResolvedUsername } from "./credential-username.js";
|
||||||
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";
|
||||||
@@ -41,7 +38,6 @@ 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";
|
||||||
@@ -49,7 +45,6 @@ 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 {
|
||||||
@@ -62,12 +57,9 @@ 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();
|
||||||
@@ -112,7 +104,6 @@ interface SSHHostWithCredentials {
|
|||||||
rdpPort?: number;
|
rdpPort?: number;
|
||||||
vncPort?: number;
|
vncPort?: number;
|
||||||
telnetPort?: number;
|
telnetPort?: number;
|
||||||
vaultProfile?: { id?: number | null } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusEntry = {
|
type StatusEntry = {
|
||||||
@@ -172,9 +163,6 @@ 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(() => {
|
||||||
@@ -182,81 +170,6 @@ 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;
|
||||||
@@ -390,17 +303,14 @@ class PollingManager {
|
|||||||
this.pollingConfigs.set(host.id, config);
|
this.pollingConfigs.set(host.id, config);
|
||||||
|
|
||||||
if (isTcpPingEnabled(statsConfig)) {
|
if (isTcpPingEnabled(statsConfig)) {
|
||||||
const intervalMs = this.intervalWithJitter(
|
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||||
statsConfig.statusCheckInterval * 1000,
|
|
||||||
host.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.scheduleStatusPoll(host, viewerUserId);
|
this.pollHostStatus(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.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
|
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
} else {
|
} else {
|
||||||
@@ -408,27 +318,9 @@ class PollingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
||||||
const intervalMs = this.intervalWithJitter(
|
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||||
statsConfig.metricsInterval * 1000,
|
|
||||||
host.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// First sample still awaited (gated) so callers can rely on a warm cache.
|
await this.pollHostMetrics(host, viewerUserId);
|
||||||
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);
|
||||||
@@ -437,10 +329,15 @@ class PollingManager {
|
|||||||
latestConfig.statsConfig.metricsEnabled &&
|
latestConfig.statsConfig.metricsEnabled &&
|
||||||
supportsMetrics(latestConfig.host)
|
supportsMetrics(latestConfig.host)
|
||||||
) {
|
) {
|
||||||
this.scheduleMetricsPoll(
|
this.pollHostMetrics(
|
||||||
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 {
|
||||||
@@ -453,51 +350,30 @@ class PollingManager {
|
|||||||
viewerUserId?: string,
|
viewerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = viewerUserId || host.userId;
|
const userId = viewerUserId || host.userId;
|
||||||
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
const refreshedHost = await fetchHostById(host.id, 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 proxyConfig: SOCKS5Config | null =
|
const firstJump = await fetchHostById(
|
||||||
refreshedHost.useSocks5 &&
|
refreshedHost.jumpHosts[0].hostId,
|
||||||
(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,
|
|
||||||
);
|
);
|
||||||
isOnline = jumpClient
|
if (firstJump) {
|
||||||
? await tcpPingThroughJumpHost(
|
pingHost = firstJump.ip;
|
||||||
jumpClient,
|
pingPort = firstJump.port;
|
||||||
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(),
|
||||||
@@ -523,7 +399,7 @@ class PollingManager {
|
|||||||
viewerUserId?: string,
|
viewerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = viewerUserId || host.userId;
|
const userId = viewerUserId || host.userId;
|
||||||
const refreshedHost = await this.resolveHostForPoll(host, userId);
|
const refreshedHost = await fetchHostById(host.id, userId);
|
||||||
if (!refreshedHost) {
|
if (!refreshedHost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -678,9 +554,6 @@ 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 {
|
||||||
@@ -1087,10 +960,9 @@ async function resolveHostCredentials(
|
|||||||
host.overrideCredentialUsername,
|
host.overrideCredentialUsername,
|
||||||
);
|
);
|
||||||
|
|
||||||
baseHost.password = pickResolvedPassword(
|
if (credential.password) {
|
||||||
host.password,
|
baseHost.password = credential.password;
|
||||||
credential.password,
|
}
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
credential.key ||
|
credential.key ||
|
||||||
(credential as Record<string, unknown>).privateKey
|
(credential as Record<string, unknown>).privateKey
|
||||||
@@ -1256,8 +1128,6 @@ 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;
|
||||||
@@ -1316,10 +1186,6 @@ 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 =
|
||||||
@@ -1465,18 +1331,8 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
|||||||
client.connect(config);
|
client.connect(config);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (config.sock) {
|
|
||||||
client.connect(config);
|
|
||||||
} else {
|
} else {
|
||||||
resolveSshConnectConfigHost(config)
|
|
||||||
.then(() => {
|
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1789,11 +1645,8 @@ 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()) {
|
||||||
const access = await permissionManager.canAccessHost(userId, id, "read");
|
|
||||||
if (access.hasAccess) {
|
|
||||||
result[id] = entry;
|
result[id] = entry;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1830,11 +1683,6 @@ 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);
|
||||||
@@ -1862,7 +1710,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", requireAdmin, async (req, res) => {
|
app.post("/clear-connections", async (req, res) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||||
@@ -1881,7 +1729,7 @@ app.post("/clear-connections", requireAdmin, async (req, res) => {
|
|||||||
* /refresh:
|
* /refresh:
|
||||||
* post:
|
* post:
|
||||||
* summary: Refresh polling
|
* summary: Refresh polling
|
||||||
* description: Refreshes host polling for the authenticated user.
|
* description: Clears all SSH connections and refreshes host polling.
|
||||||
* tags:
|
* tags:
|
||||||
* - Host Metrics
|
* - Host Metrics
|
||||||
* responses:
|
* responses:
|
||||||
@@ -1900,6 +1748,8 @@ 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" });
|
||||||
});
|
});
|
||||||
@@ -1949,7 +1799,6 @@ 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));
|
||||||
@@ -2221,10 +2070,6 @@ 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<{
|
||||||
@@ -2512,17 +2357,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resolveSshConnectConfigHost(config)
|
|
||||||
.then(() => {
|
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
if (!isResolved) {
|
|
||||||
isResolved = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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";
|
||||||
@@ -20,14 +19,6 @@ 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(
|
||||||
@@ -199,7 +190,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 = pickResolvedPassword(host.password, cred.password);
|
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 { load as loadYaml } from "js-yaml";
|
import yaml 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 = loadYaml(content) as {
|
const parsed = yaml.load(content) as {
|
||||||
providers?: Array<{
|
providers?: Array<{
|
||||||
alias?: string;
|
alias?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
|
|||||||
@@ -8,29 +8,18 @@ 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 waiters = new Map<string, ConnectionWaiter[]>();
|
private maxConnectionsPerHost = 3;
|
||||||
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 {
|
||||||
@@ -49,30 +38,34 @@ class SSHConnectionPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeUnhealthy(
|
async getConnection(
|
||||||
key: string,
|
key: string,
|
||||||
connections: PooledConnection[],
|
factory: () => Promise<Client>,
|
||||||
target: PooledConnection,
|
): Promise<Client> {
|
||||||
): PooledConnection[] {
|
let connections = this.connections.get(key) || [];
|
||||||
|
|
||||||
|
const available = connections.find((conn) => !conn.inUse);
|
||||||
|
if (available) {
|
||||||
|
if (!this.isConnectionHealthy(available.client)) {
|
||||||
sshLogger.warn("Removing unhealthy connection from pool", {
|
sshLogger.warn("Removing unhealthy connection from pool", {
|
||||||
operation: "pool_remove_dead",
|
operation: "pool_remove_dead",
|
||||||
hostKey: key,
|
hostKey: key,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
target.client.end();
|
available.client.end();
|
||||||
} catch {
|
} catch {
|
||||||
// expected
|
// expected
|
||||||
}
|
}
|
||||||
const filtered = connections.filter((c) => c !== target);
|
connections = connections.filter((c) => c !== available);
|
||||||
this.connections.set(key, filtered);
|
this.connections.set(key, connections);
|
||||||
return filtered;
|
} else {
|
||||||
|
available.inUse = true;
|
||||||
|
available.lastUsed = Date.now();
|
||||||
|
return available.client;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createPooledClient(
|
if (connections.length < this.maxConnectionsPerHost) {
|
||||||
key: string,
|
|
||||||
factory: () => Promise<Client>,
|
|
||||||
existing: PooledConnection[],
|
|
||||||
): Promise<Client> {
|
|
||||||
const client = await factory();
|
const client = await factory();
|
||||||
const pooled: PooledConnection = {
|
const pooled: PooledConnection = {
|
||||||
client,
|
client,
|
||||||
@@ -80,8 +73,8 @@ class SSHConnectionPool {
|
|||||||
inUse: true,
|
inUse: true,
|
||||||
hostKey: key,
|
hostKey: key,
|
||||||
};
|
};
|
||||||
existing.push(pooled);
|
connections.push(pooled);
|
||||||
this.connections.set(key, existing);
|
this.connections.set(key, connections);
|
||||||
|
|
||||||
client.on("end", () => {
|
client.on("end", () => {
|
||||||
this.removeConnection(key, client);
|
this.removeConnection(key, client);
|
||||||
@@ -93,113 +86,51 @@ class SSHConnectionPool {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueueWaiter(
|
return new Promise((resolve) => {
|
||||||
key: string,
|
const checkAvailable = () => {
|
||||||
factory: () => Promise<Client>,
|
const conns = this.connections.get(key) || [];
|
||||||
): Promise<Client> {
|
const avail = conns.find((conn) => !conn.inUse);
|
||||||
return new Promise<Client>((resolve, reject) => {
|
if (avail) {
|
||||||
const timer = setTimeout(() => {
|
if (!this.isConnectionHealthy(avail.client)) {
|
||||||
const queue = this.waiters.get(key);
|
try {
|
||||||
if (queue) {
|
avail.client.end();
|
||||||
const idx = queue.findIndex((w) => w.timer === timer);
|
} catch {
|
||||||
if (idx >= 0) queue.splice(idx, 1);
|
// expected
|
||||||
if (queue.length === 0) this.waiters.delete(key);
|
|
||||||
}
|
}
|
||||||
const err = new Error(
|
const filtered = conns.filter((c) => c !== avail);
|
||||||
`SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
|
this.connections.set(key, filtered);
|
||||||
);
|
factory().then((client) => {
|
||||||
sshLogger.warn("Connection pool wait timeout", {
|
const pooled: PooledConnection = {
|
||||||
operation: "pool_wait_timeout",
|
client,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
inUse: true,
|
||||||
hostKey: key,
|
hostKey: key,
|
||||||
maxWaitMs: this.maxWaitMs,
|
};
|
||||||
|
filtered.push(pooled);
|
||||||
|
this.connections.set(key, filtered);
|
||||||
|
client.on("end", () => this.removeConnection(key, client));
|
||||||
|
client.on("close", () => this.removeConnection(key, client));
|
||||||
|
resolve(client);
|
||||||
});
|
});
|
||||||
reject(err);
|
} else {
|
||||||
}, this.maxWaitMs);
|
avail.inUse = true;
|
||||||
|
avail.lastUsed = Date.now();
|
||||||
const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
|
resolve(avail.client);
|
||||||
const queue = this.waiters.get(key) || [];
|
}
|
||||||
queue.push(waiter);
|
} else {
|
||||||
this.waiters.set(key, queue);
|
setTimeout(checkAvailable, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAvailable();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hand a free connection (or new slot) to the next waiter, if any. */
|
|
||||||
private wakeWaiter(key: string): void {
|
|
||||||
const queue = this.waiters.get(key);
|
|
||||||
if (!queue || queue.length === 0) return;
|
|
||||||
|
|
||||||
let connections = this.connections.get(key) || [];
|
|
||||||
const available = connections.find((conn) => !conn.inUse);
|
|
||||||
|
|
||||||
if (available) {
|
|
||||||
if (!this.isConnectionHealthy(available.client)) {
|
|
||||||
connections = this.removeUnhealthy(key, connections, available);
|
|
||||||
// Fall through — may create or wait again.
|
|
||||||
} else {
|
|
||||||
const waiter = queue.shift()!;
|
|
||||||
if (queue.length === 0) this.waiters.delete(key);
|
|
||||||
else this.waiters.set(key, queue);
|
|
||||||
clearTimeout(waiter.timer);
|
|
||||||
available.inUse = true;
|
|
||||||
available.lastUsed = Date.now();
|
|
||||||
waiter.resolve(available.client);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connections.length < this.maxConnectionsPerHost) {
|
|
||||||
const waiter = queue.shift()!;
|
|
||||||
if (queue.length === 0) this.waiters.delete(key);
|
|
||||||
else this.waiters.set(key, queue);
|
|
||||||
clearTimeout(waiter.timer);
|
|
||||||
this.createPooledClient(key, waiter.factory, connections)
|
|
||||||
.then(waiter.resolve)
|
|
||||||
.catch(waiter.reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rejectWaiters(key: string, reason: string): void {
|
|
||||||
const queue = this.waiters.get(key);
|
|
||||||
if (!queue) return;
|
|
||||||
this.waiters.delete(key);
|
|
||||||
for (const waiter of queue) {
|
|
||||||
clearTimeout(waiter.timer);
|
|
||||||
waiter.reject(new Error(reason));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getConnection(
|
|
||||||
key: string,
|
|
||||||
factory: () => Promise<Client>,
|
|
||||||
): Promise<Client> {
|
|
||||||
let connections = this.connections.get(key) || [];
|
|
||||||
|
|
||||||
const available = connections.find((conn) => !conn.inUse);
|
|
||||||
if (available) {
|
|
||||||
if (!this.isConnectionHealthy(available.client)) {
|
|
||||||
connections = this.removeUnhealthy(key, connections, available);
|
|
||||||
} else {
|
|
||||||
available.inUse = true;
|
|
||||||
available.lastUsed = Date.now();
|
|
||||||
return available.client;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connections.length < this.maxConnectionsPerHost) {
|
|
||||||
return this.createPooledClient(key, factory, connections);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.enqueueWaiter(key, factory);
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseConnection(key: string, client: Client): void {
|
releaseConnection(key: string, client: Client): void {
|
||||||
const connections = this.connections.get(key) || [];
|
const connections = this.connections.get(key) || [];
|
||||||
const pooled = connections.find((conn) => conn.client === client);
|
const pooled = connections.find((conn) => conn.client === client);
|
||||||
if (pooled) {
|
if (pooled) {
|
||||||
pooled.inUse = false;
|
pooled.inUse = false;
|
||||||
pooled.lastUsed = Date.now();
|
pooled.lastUsed = Date.now();
|
||||||
this.wakeWaiter(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,8 +143,6 @@ 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 {
|
||||||
@@ -226,15 +155,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 > IDLE_MAX_AGE_MS) {
|
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||||
try {
|
try {
|
||||||
conn.client.end();
|
conn.client.end();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -258,14 +187,10 @@ 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 {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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,19 +8,6 @@ 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;
|
||||||
@@ -110,59 +97,34 @@ 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 port = Number(knock.port);
|
const protocol = knock.protocol || "tcp";
|
||||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
const delay = knock.delay ?? 100;
|
||||||
|
|
||||||
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 = createUdpSocket();
|
const client = dgram.createSocket("udp4");
|
||||||
let settled = false;
|
client.send(Buffer.alloc(0), knock.port, host, () => {
|
||||||
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 = createTcpSocket();
|
const socket = new net.Socket();
|
||||||
let settled = false;
|
socket.once("connect", () => {
|
||||||
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("connect", finish);
|
socket.once("error", () => {
|
||||||
socket.once("error", finish);
|
socket.destroy();
|
||||||
socket.connect(port, host);
|
resolve();
|
||||||
|
});
|
||||||
|
socket.connect(knock.port, host);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
await wait(delay);
|
await new Promise<void>((r) => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,8 +1,7 @@
|
|||||||
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 mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]);
|
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||||
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", () => ({
|
||||||
@@ -30,25 +29,21 @@ 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: mockUnlink,
|
unlink: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
promises: {
|
promises: {
|
||||||
mkdir: mockMkdir,
|
mkdir: mockMkdir,
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
appendFile: mockAppendFile,
|
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
unlink: mockUnlink,
|
unlink: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -60,8 +55,7 @@ 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);
|
||||||
mockReturning.mockResolvedValue([{ id: 1 }]);
|
mockInsertValues.mockResolvedValue(undefined);
|
||||||
mockInsertValues.mockReturnValue({ returning: mockReturning });
|
|
||||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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";
|
||||||
@@ -37,12 +36,6 @@ 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;
|
||||||
@@ -124,19 +117,6 @@ 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,
|
||||||
@@ -155,12 +135,6 @@ 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,
|
||||||
@@ -360,9 +334,6 @@ 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 {
|
||||||
@@ -405,14 +376,31 @@ 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.recordingBytes === 0) return;
|
if (session.outputBufferBytes === 0) return;
|
||||||
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
|
// Only save if new output arrived since last persist (unless forced)
|
||||||
session.lastPersistedBytes = session.recordingBytes;
|
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
|
||||||
session.recordingPersistChain = session.recordingPersistChain
|
return;
|
||||||
.then(() => this.persistSessionLog(session))
|
const snapshot = session.outputBuffer.join("");
|
||||||
.catch((err) => {
|
session.lastPersistedBytes = session.outputBufferBytes;
|
||||||
|
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,
|
||||||
@@ -421,35 +409,32 @@ class TerminalSessionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistSessionLog(session: TerminalSession): Promise<void> {
|
private async persistSessionLog(
|
||||||
if (!session.recordingPath) return;
|
session: TerminalSession,
|
||||||
await session.recordingWriteChain;
|
logContent: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const cleaned = this.stripAnsi(logContent);
|
||||||
|
if (!cleaned.trim()) return;
|
||||||
|
|
||||||
|
const userLogDir = path.join(SESSION_LOGS_DIR, session.userId);
|
||||||
|
await fs.promises.mkdir(userLogDir, { recursive: true });
|
||||||
|
|
||||||
|
const logFile = path.join(userLogDir, `${session.id}.log`);
|
||||||
|
await fs.promises.writeFile(logFile, cleaned, "utf-8");
|
||||||
|
|
||||||
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();
|
||||||
if (session.recordingId == null) {
|
await db.insert(sessionRecordings).values({
|
||||||
const rows = await db
|
|
||||||
.insert(sessionRecordings)
|
|
||||||
.values({
|
|
||||||
hostId: session.hostId,
|
hostId: session.hostId,
|
||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
startedAt: new Date(session.sessionStartedAt).toISOString(),
|
||||||
endedAt: new Date(endedAt).toISOString(),
|
endedAt: new Date(endedAt).toISOString(),
|
||||||
duration,
|
duration,
|
||||||
recordingPath: session.recordingPath,
|
recordingPath: logFile,
|
||||||
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",
|
||||||
@@ -464,7 +449,7 @@ class TerminalSessionManager {
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
hostId: session.hostId,
|
hostId: session.hostId,
|
||||||
duration,
|
duration,
|
||||||
bytes: session.recordingBytes,
|
bytes: cleaned.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,47 +477,6 @@ 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,7 +37,6 @@ 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;
|
||||||
@@ -539,9 +538,6 @@ 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) {
|
||||||
@@ -1146,7 +1142,6 @@ 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;
|
||||||
})
|
})
|
||||||
@@ -1190,20 +1185,6 @@ 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}`, {
|
||||||
@@ -1286,39 +1267,6 @@ 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", () => {
|
||||||
@@ -2357,7 +2305,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: connectHost,
|
host: ip,
|
||||||
port,
|
port,
|
||||||
username,
|
username,
|
||||||
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
||||||
@@ -2916,7 +2864,6 @@ 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,52 +1,20 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import type { Client } from "ssh2";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { detectTmux, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
|
import { 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; export PATH; command -v tmux");
|
expect(command).toContain(":$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(
|
||||||
/^\/bin\/sh -c 'PATH=.*:\$PATH; export PATH; tmux list-sessions'$/,
|
/^PATH=.*:\$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,8 +22,7 @@ const TMUX_PATH_DIRS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function withTmuxPath(command: string): string {
|
export function withTmuxPath(command: string): string {
|
||||||
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; export PATH; ${command}`;
|
return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`;
|
||||||
return `/bin/sh -c ${shellEscape(script)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tmuxCommand(args: string): string {
|
export function tmuxCommand(args: string): string {
|
||||||
@@ -70,7 +69,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, tmuxCommand("-V"));
|
await execCommand(conn, withTmuxPath("command -v tmux"));
|
||||||
} catch {
|
} catch {
|
||||||
return { available: false, sessions: [] };
|
return { available: false, sessions: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ 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 { resolveSshConnectConfigHost } from "./ssh-dns.js";
|
import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
|
||||||
import { execCommand, tmuxCommand } from "./tmux-helper.js";
|
|
||||||
import {
|
import {
|
||||||
SEP,
|
SEP,
|
||||||
parseSessions,
|
parseSessions,
|
||||||
@@ -96,8 +95,6 @@ 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>,
|
||||||
@@ -121,12 +118,6 @@ 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 ||
|
||||||
@@ -210,17 +201,8 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
|
|||||||
client.connect(config);
|
client.connect(config);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (config.sock) {
|
|
||||||
client.connect(config);
|
|
||||||
} else {
|
} else {
|
||||||
resolveSshConnectConfigHost(config)
|
|
||||||
.then(() => {
|
|
||||||
client.connect(config);
|
client.connect(config);
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -246,7 +228,7 @@ async function withHostConnection<T>(
|
|||||||
|
|
||||||
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
async function tmuxAvailable(conn: Client): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execCommand(conn, tmuxCommand("-V"));
|
await execCommand(conn, withTmuxPath("command -v tmux"));
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ 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();
|
||||||
@@ -1508,27 +1507,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1715,10 +1693,6 @@ 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));
|
||||||
@@ -1963,7 +1937,6 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tunnelName = tunnelConfig.name;
|
const tunnelName = tunnelConfig.name;
|
||||||
tunnelConfig.requestingUserId = userId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||||
@@ -1994,6 +1967,10 @@ 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)) {
|
||||||
@@ -2056,47 +2033,22 @@ 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 =
|
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
|
||||||
endpointHost.userId === userId
|
tunnelConfig.endpointUserId = endpointHost.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) {
|
if (endpointHost.id && endpointHost.userId) {
|
||||||
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,
|
||||||
userId,
|
endpointHost.userId,
|
||||||
);
|
);
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
tunnelConfig.endpointPassword = resolved.password;
|
tunnelConfig.endpointPassword = resolved.password;
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
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, inArray, sql } from "drizzle-orm";
|
import { eq, and, 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,7 +42,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -346,9 +345,6 @@ 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();
|
||||||
@@ -395,9 +391,6 @@ 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,
|
||||||
@@ -658,62 +651,6 @@ 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
|
||||||
@@ -882,7 +819,6 @@ 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,5 +1,4 @@
|
|||||||
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;
|
||||||
@@ -37,7 +36,7 @@ async function fetchWithRetry(
|
|||||||
options: RequestInit,
|
options: RequestInit,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const attempt = async () => {
|
const attempt = async () => {
|
||||||
const res = await safeOutboundFetch(url, options);
|
const res = await fetch(url, options);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,7 +3,6 @@ import {
|
|||||||
getRequestBasePath,
|
getRequestBasePath,
|
||||||
getRequestBaseUrl,
|
getRequestBaseUrl,
|
||||||
getRequestBaseUrlWithForceHTTPS,
|
getRequestBaseUrlWithForceHTTPS,
|
||||||
getRequestOrigin,
|
|
||||||
normalizeBasePath,
|
normalizeBasePath,
|
||||||
} from "./request-origin.js";
|
} from "./request-origin.js";
|
||||||
|
|
||||||
@@ -107,52 +106,3 @@ 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,44 +7,6 @@ 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();
|
||||||
@@ -83,23 +45,38 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
|||||||
: "http";
|
: "http";
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = normalizePort(req.headers["x-forwarded-port"]);
|
const portHeader = req.headers["x-forwarded-port"];
|
||||||
const { host, port: hostPort } = splitHostHeader(
|
let port: string | undefined =
|
||||||
req.headers["x-forwarded-host"] || req.headers.host,
|
typeof portHeader === "string"
|
||||||
);
|
? portHeader.split(",")[0].trim()
|
||||||
port ||= hostPort;
|
: undefined;
|
||||||
|
|
||||||
|
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}://${host}`
|
? `${protocol}://${hostWithoutPort}`
|
||||||
: `${protocol}://${host}:${port}`;
|
: `${protocol}://${hostWithoutPort}:${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${protocol}://${host}`;
|
return `${protocol}://${hostWithoutPort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequestOriginWithForceHTTPS(
|
export function getRequestOriginWithForceHTTPS(
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
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,7 +3,6 @@ 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;
|
||||||
@@ -77,15 +76,12 @@ class UserCrypto {
|
|||||||
let DEK: Buffer;
|
let DEK: Buffer;
|
||||||
|
|
||||||
if (existingEncryptedDEK) {
|
if (existingEncryptedDEK) {
|
||||||
DEK = await this.decryptSystemWrappedDEK(
|
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||||
existingEncryptedDEK,
|
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
|
||||||
userId,
|
systemKey.fill(0);
|
||||||
"oidc",
|
|
||||||
`user_encrypted_dek_${userId}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
|
||||||
const systemKey = await this.deriveOIDCSystemKey(userId);
|
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encryptedDEK = this.encryptDEK(DEK, systemKey);
|
const encryptedDEK = this.encryptDEK(DEK, systemKey);
|
||||||
@@ -176,12 +172,9 @@ class UserCrypto {
|
|||||||
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId);
|
||||||
|
|
||||||
if (oidcEncryptedDEK) {
|
if (oidcEncryptedDEK) {
|
||||||
const DEK = await this.decryptSystemWrappedDEK(
|
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||||
oidcEncryptedDEK,
|
const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey);
|
||||||
userId,
|
systemKey.fill(0);
|
||||||
"oidc",
|
|
||||||
`user_encrypted_dek_oidc_${userId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
databaseLogger.error(
|
databaseLogger.error(
|
||||||
@@ -217,12 +210,9 @@ class UserCrypto {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEK = await this.decryptSystemWrappedDEK(
|
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||||
encryptedDEK,
|
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
||||||
userId,
|
systemKey.fill(0);
|
||||||
"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);
|
||||||
@@ -264,7 +254,7 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = await this.deriveWebAuthnSystemKey(userId);
|
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
||||||
const encryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
const encryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
@@ -281,12 +271,9 @@ class UserCrypto {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEK = await this.decryptSystemWrappedDEK(
|
const systemKey = this.deriveWebAuthnSystemKey(userId);
|
||||||
encryptedDEK,
|
const DEK = this.decryptDEK(encryptedDEK, systemKey);
|
||||||
userId,
|
systemKey.fill(0);
|
||||||
"webauthn",
|
|
||||||
`user_encrypted_dek_webauthn_${userId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!DEK || DEK.length === 0) {
|
if (!DEK || DEK.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -465,7 +452,7 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemKey = await this.deriveOIDCSystemKey(userId);
|
const systemKey = this.deriveOIDCSystemKey(userId);
|
||||||
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey);
|
||||||
systemKey.fill(0);
|
systemKey.fill(0);
|
||||||
|
|
||||||
@@ -569,68 +556,12 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deriveOIDCSystemKey(userId: string): Promise<Buffer> {
|
private deriveOIDCSystemKey(userId: string): Buffer {
|
||||||
if (process.env.OIDC_SYSTEM_SECRET) {
|
const systemSecret =
|
||||||
|
process.env.OIDC_SYSTEM_SECRET || "termix-oidc-system-secret-default";
|
||||||
|
const salt = Buffer.from(userId, "utf8");
|
||||||
return crypto.pbkdf2Sync(
|
return crypto.pbkdf2Sync(
|
||||||
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,
|
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(
|
|
||||||
secret,
|
|
||||||
salt,
|
salt,
|
||||||
100000,
|
100000,
|
||||||
UserCrypto.KEK_LENGTH,
|
UserCrypto.KEK_LENGTH,
|
||||||
@@ -638,39 +569,19 @@ class UserCrypto {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async decryptSystemWrappedDEK(
|
private deriveWebAuthnSystemKey(userId: string): Buffer {
|
||||||
encryptedDEK: EncryptedDEK,
|
const systemSecret =
|
||||||
userId: string,
|
process.env.WEBAUTHN_SYSTEM_SECRET ||
|
||||||
type: "oidc" | "webauthn",
|
process.env.OIDC_SYSTEM_SECRET ||
|
||||||
settingKey: string,
|
"termix-webauthn-system-secret-default";
|
||||||
): Promise<Buffer> {
|
const salt = Buffer.from(`webauthn:${userId}`, "utf8");
|
||||||
const systemKey =
|
return crypto.pbkdf2Sync(
|
||||||
type === "oidc"
|
systemSecret,
|
||||||
? await this.deriveOIDCSystemKey(userId)
|
salt,
|
||||||
: await this.deriveWebAuthnSystemKey(userId);
|
100000,
|
||||||
try {
|
UserCrypto.KEK_LENGTH,
|
||||||
return this.decryptDEK(encryptedDEK, systemKey);
|
"sha256",
|
||||||
} 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,7 +10,6 @@ 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";
|
||||||
@@ -341,8 +340,6 @@ 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,26 +41,6 @@ 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;
|
||||||
|
|||||||
+1
-5
@@ -109,8 +109,7 @@ export interface Host {
|
|||||||
| "none"
|
| "none"
|
||||||
| "opkssh"
|
| "opkssh"
|
||||||
| "tailscale"
|
| "tailscale"
|
||||||
| "agent"
|
| "agent";
|
||||||
| "vault";
|
|
||||||
useWarpgate?: boolean;
|
useWarpgate?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -124,8 +123,6 @@ 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;
|
||||||
@@ -955,7 +952,6 @@ 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;
|
||||||
|
|||||||
+22
-116
@@ -6,101 +6,30 @@ 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 {
|
import { useState, useRef, useCallback, useEffect, createRef } from "react";
|
||||||
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,
|
||||||
@@ -130,10 +59,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[],
|
||||||
@@ -689,7 +618,8 @@ export function AppShell({
|
|||||||
applyAccentColor(prefs.accentColor);
|
applyAccentColor(prefs.accentColor);
|
||||||
}
|
}
|
||||||
if (prefs.language && prefs.language !== i18n.language) {
|
if (prefs.language && prefs.language !== i18n.language) {
|
||||||
void changeAppLanguage(prefs.language);
|
localStorage.setItem("i18nextLng", prefs.language);
|
||||||
|
void i18n.changeLanguage(prefs.language);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
prefs.commandAutocomplete !== null &&
|
prefs.commandAutocomplete !== null &&
|
||||||
@@ -808,17 +738,8 @@ export function AppShell({
|
|||||||
}, [loadHosts]);
|
}, [loadHosts]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHostsChanged = () => {
|
window.addEventListener("termix:hosts-changed", loadHosts);
|
||||||
void loadHosts();
|
return () => window.removeEventListener("termix:hosts-changed", 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)
|
||||||
@@ -1516,7 +1437,6 @@ 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 = (
|
||||||
<Suspense fallback={<SidebarPanelFallback />}>
|
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
|
||||||
@@ -1587,10 +1507,7 @@ export function AppShell({
|
|||||||
|
|
||||||
{railView === "history" && (
|
{railView === "history" && (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
<HistoryPanel
|
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} />
|
||||||
terminalTabs={terminalTabs}
|
|
||||||
activeTabId={activeTabId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1683,7 +1600,6 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sidebar header — shared
|
// Sidebar header — shared
|
||||||
@@ -1797,10 +1713,6 @@ 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}
|
||||||
/>
|
/>
|
||||||
@@ -1896,8 +1808,6 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{commandPaletteOpen && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
isOpen={commandPaletteOpen}
|
isOpen={commandPaletteOpen}
|
||||||
setIsOpen={setCommandPaletteOpen}
|
setIsOpen={setCommandPaletteOpen}
|
||||||
@@ -1925,12 +1835,8 @@ export function AppShell({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
<TransferMonitor />
|
<TransferMonitor />
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AlertManager userId={userId} loggedIn={!!username} />
|
<AlertManager userId={userId} loggedIn={!!username} />
|
||||||
</Suspense>
|
|
||||||
</ServerStatusProvider>
|
</ServerStatusProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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,28 +40,6 @@ 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[]
|
||||||
> {
|
> {
|
||||||
@@ -113,23 +91,6 @@ 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);
|
||||||
@@ -160,13 +121,7 @@ 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 });
|
||||||
const data = res.data as { firings?: unknown } | unknown[];
|
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
|
||||||
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> {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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,40 +34,6 @@ 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> {
|
||||||
@@ -84,14 +50,7 @@ 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()}`);
|
||||||
const data = response.data as Partial<AuditLogResponse>;
|
return response.data;
|
||||||
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,7 +1,6 @@
|
|||||||
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";
|
||||||
@@ -74,7 +73,6 @@ function isTransientStatusError(error: unknown): boolean {
|
|||||||
export async function getAllServerStatuses(): Promise<
|
export async function getAllServerStatuses(): Promise<
|
||||||
Record<number, ServerStatus>
|
Record<number, ServerStatus>
|
||||||
> {
|
> {
|
||||||
return getCachedServerStatuses(async () => {
|
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||||
@@ -103,8 +101,6 @@ export async function getAllServerStatuses(): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,5 +1,4 @@
|
|||||||
import { authApi } from "@/main-axios";
|
import { authApi } from "@/main-axios";
|
||||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
|
||||||
|
|
||||||
// OPEN TABS API
|
// OPEN TABS API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -43,8 +42,6 @@ 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;
|
||||||
@@ -72,10 +69,8 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||||
return activeSessionsCache.get(async () => {
|
|
||||||
const response = await authApi.get("/open-tabs/active-sessions");
|
const response = await authApi.get("/open-tabs/active-sessions");
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
return response.data;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ 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[]> {
|
||||||
@@ -25,36 +22,6 @@ 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.postForm(
|
const response = await fileManagerApi.post(
|
||||||
"/ssh/uploadFileChunk",
|
"/ssh/uploadFileChunk",
|
||||||
form,
|
form,
|
||||||
{ timeout: 0 },
|
{ timeout: 0 },
|
||||||
|
|||||||
@@ -8,38 +8,16 @@ 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 type GetSSHHostsOptions = {
|
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||||
/** 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 hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
const hostsResponse = await sshHostApi.get("/db/host");
|
||||||
|
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
||||||
if (!includeStatus) {
|
? hostsResponse.data
|
||||||
return hosts.map((host) => ({
|
: [];
|
||||||
...host,
|
|
||||||
status: "unknown",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
let statuses: Record<number, ServerStatus> = {};
|
let statuses: Record<number, ServerStatus> = {};
|
||||||
try {
|
try {
|
||||||
@@ -67,11 +45,9 @@ 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");
|
||||||
@@ -91,11 +67,9 @@ 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");
|
||||||
@@ -129,7 +103,6 @@ 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");
|
||||||
@@ -152,7 +125,6 @@ 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");
|
||||||
@@ -183,7 +155,6 @@ 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");
|
||||||
@@ -195,7 +166,6 @@ 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 { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
import i18n 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(
|
||||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
() => localStorage.getItem("i18nextLng") ?? "en",
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleLanguageChange(code: string) {
|
function handleLanguageChange(code: string) {
|
||||||
void changeAppLanguage(code)
|
setLanguage(code);
|
||||||
.then((language) => setLanguage(language))
|
localStorage.setItem("i18nextLng", code);
|
||||||
.catch(() => {});
|
i18n.changeLanguage(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
|
|||||||
@@ -531,10 +531,6 @@ function HostStatusCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStatusCheckEnabled(host: Host): boolean {
|
|
||||||
return host.statsConfig?.statusCheckEnabled !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecentActivityCard({
|
function RecentActivityCard({
|
||||||
activity,
|
activity,
|
||||||
hosts,
|
hosts,
|
||||||
@@ -801,7 +797,6 @@ function CardItem({
|
|||||||
onAddServiceLink,
|
onAddServiceLink,
|
||||||
onDeleteServiceLink,
|
onDeleteServiceLink,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
isVisible = true,
|
|
||||||
}: {
|
}: {
|
||||||
slot: CardSlot;
|
slot: CardSlot;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
@@ -831,7 +826,6 @@ 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);
|
||||||
|
|
||||||
@@ -928,7 +922,6 @@ 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")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1058,7 +1051,6 @@ 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({
|
||||||
@@ -1090,7 +1082,6 @@ 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);
|
||||||
@@ -1147,7 +1138,6 @@ function PanelColumn({
|
|||||||
onAddServiceLink={onAddServiceLink}
|
onAddServiceLink={onAddServiceLink}
|
||||||
onDeleteServiceLink={onDeleteServiceLink}
|
onDeleteServiceLink={onDeleteServiceLink}
|
||||||
statusLoading={statusLoading}
|
statusLoading={statusLoading}
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1198,12 +1188,9 @@ 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();
|
||||||
@@ -1296,7 +1283,6 @@ 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 }> = {};
|
||||||
@@ -1356,11 +1342,8 @@ 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);
|
||||||
if (isVisible) {
|
fetchMetrics(mapped).catch(() => {});
|
||||||
fetchMetrics(statusHosts).catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
|
|
||||||
@@ -1410,37 +1393,28 @@ 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(statusHosts).catch(() => {});
|
fetchMetrics(mapped).catch(() => {});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
clearInterval(metricsInterval);
|
clearInterval(metricsInterval);
|
||||||
};
|
};
|
||||||
}, [fetchMetrics, isVisible]);
|
}, [fetchMetrics]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible || viewerSessionsRef.current.size === 0) return;
|
if (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, isVisible]);
|
}, [hostMetrics]);
|
||||||
|
|
||||||
const handleClearActivity = async () => {
|
const handleClearActivity = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1613,7 +1587,6 @@ export function DashboardTab({
|
|||||||
onAddServiceLink: handleAddServiceLink,
|
onAddServiceLink: handleAddServiceLink,
|
||||||
onDeleteServiceLink: handleDeleteServiceLink,
|
onDeleteServiceLink: handleDeleteServiceLink,
|
||||||
statusLoading,
|
statusLoading,
|
||||||
isVisible,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -1734,7 +1707,7 @@ export function DashboardTab({
|
|||||||
)}
|
)}
|
||||||
{slot.id === "host_status" && (
|
{slot.id === "host_status" && (
|
||||||
<HostStatusCard
|
<HostStatusCard
|
||||||
hosts={statusCheckHosts}
|
hosts={hosts}
|
||||||
hostMetrics={hostMetrics}
|
hostMetrics={hostMetrics}
|
||||||
onOpenTab={onOpenTab}
|
onOpenTab={onOpenTab}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -1753,7 +1726,6 @@ 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,8 +92,6 @@ 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;
|
||||||
@@ -197,7 +195,6 @@ 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();
|
||||||
@@ -272,19 +269,8 @@ export function NetworkGraphCard({
|
|||||||
}, [hostMap]);
|
}, [hostMap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
|
||||||
if (statusIntervalRef.current) {
|
|
||||||
clearInterval(statusIntervalRef.current);
|
|
||||||
statusIntervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
statusIntervalRef.current = setInterval(() => {
|
statusIntervalRef.current = setInterval(updateHostStatuses, 30000);
|
||||||
if (document.visibilityState === "hidden") return;
|
|
||||||
updateHostStatuses();
|
|
||||||
}, 30000);
|
|
||||||
const onClickOutside = (e: MouseEvent) => {
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
contextMenuRef.current &&
|
contextMenuRef.current &&
|
||||||
@@ -307,7 +293,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,10 +335,7 @@ function DockerManagerInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
pollContainers();
|
pollContainers();
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(pollContainers, 5000);
|
||||||
if (document.visibilityState === "hidden") return;
|
|
||||||
void pollContainers();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -50,10 +50,7 @@ export function ContainerStats({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(fetchStats, 2000);
|
||||||
if (document.visibilityState === "hidden") return;
|
|
||||||
void fetchStats();
|
|
||||||
}, 2000);
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStats]);
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
|||||||
@@ -90,10 +90,7 @@ export function LogViewer({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(fetchLogs, 3000);
|
||||||
if (document.visibilityState === "hidden") return;
|
|
||||||
void fetchLogs();
|
|
||||||
}, 3000);
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [autoRefresh, fetchLogs]);
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ function FileManagerContent({
|
|||||||
initialPath,
|
initialPath,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenTerminalTab,
|
onOpenTerminalTab,
|
||||||
isVisible = true,
|
|
||||||
}: FileManagerProps) {
|
}: FileManagerProps) {
|
||||||
const { openWindow } = useWindowManager();
|
const { openWindow } = useWindowManager();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -269,7 +268,7 @@ function FileManagerContent({
|
|||||||
}, [currentHost]);
|
}, [currentHost]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sshSessionId && isVisible) {
|
if (sshSessionId) {
|
||||||
startKeepalive();
|
startKeepalive();
|
||||||
} else {
|
} else {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
@@ -278,7 +277,7 @@ function FileManagerContent({
|
|||||||
return () => {
|
return () => {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
};
|
};
|
||||||
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
|
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
||||||
|
|
||||||
const initialFileOpenedRef = useRef(false);
|
const initialFileOpenedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3106,7 +3105,6 @@ function FileManagerInner({
|
|||||||
initialPath,
|
initialPath,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenTerminalTab,
|
onOpenTerminalTab,
|
||||||
isVisible = true,
|
|
||||||
}: FileManagerProps) {
|
}: FileManagerProps) {
|
||||||
return (
|
return (
|
||||||
<WindowManager>
|
<WindowManager>
|
||||||
@@ -3116,7 +3114,6 @@ function FileManagerInner({
|
|||||||
initialPath={initialPath}
|
initialPath={initialPath}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onOpenTerminalTab={onOpenTerminalTab}
|
onOpenTerminalTab={onOpenTerminalTab}
|
||||||
isVisible={isVisible}
|
|
||||||
/>
|
/>
|
||||||
</WindowManager>
|
</WindowManager>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, {
|
import React, { useState, useRef, useCallback, useEffect } from "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,
|
||||||
@@ -193,12 +185,6 @@ 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",
|
||||||
@@ -206,52 +192,6 @@ 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) {
|
||||||
@@ -502,77 +442,6 @@ 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[] = [];
|
||||||
@@ -588,6 +457,13 @@ 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 ||
|
||||||
@@ -610,16 +486,7 @@ export function FileManagerGrid({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[isSelecting, selectionStart, files, onSelectionChange],
|
||||||
isSelecting,
|
|
||||||
selectionStart,
|
|
||||||
files,
|
|
||||||
onSelectionChange,
|
|
||||||
viewMode,
|
|
||||||
createIntent,
|
|
||||||
gridCols,
|
|
||||||
gridRowCount,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(
|
const handleMouseUp = useCallback(
|
||||||
@@ -952,48 +819,19 @@ export function FileManagerGrid({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
|
||||||
{createIntent && (
|
{createIntent && (
|
||||||
<div
|
|
||||||
className="grid gap-4"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CreateIntentGridItem
|
<CreateIntentGridItem
|
||||||
intent={createIntent}
|
intent={createIntent}
|
||||||
onConfirm={onConfirmCreate}
|
onConfirm={onConfirmCreate}
|
||||||
onCancel={onCancelCreate}
|
onCancel={onCancelCreate}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div
|
{files.map((file) => {
|
||||||
className="relative w-full"
|
|
||||||
style={{ height: gridVirtualizer.getTotalSize() }}
|
|
||||||
>
|
|
||||||
{gridVirtualizer.getVirtualItems().map((vRow) => {
|
|
||||||
const start = vRow.index * gridCols;
|
|
||||||
const rowFiles = files.slice(start, start + gridCols);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={vRow.key}
|
|
||||||
data-index={vRow.index}
|
|
||||||
ref={gridVirtualizer.measureElement}
|
|
||||||
className="absolute top-0 left-0 w-full"
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${vRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="grid gap-4 pb-4"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rowFiles.map((file) => {
|
|
||||||
const isSelected = selectedFiles.some(
|
const isSelected = selectedFiles.some(
|
||||||
(f) => f.path === file.path,
|
(f) => f.path === file.path,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
@@ -1001,13 +839,11 @@ export function FileManagerGrid({
|
|||||||
draggable={true}
|
draggable={true}
|
||||||
className={cn(
|
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",
|
"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 &&
|
isSelected && "bg-accent-brand/10 border-accent-brand/40",
|
||||||
"bg-accent-brand/10 border-accent-brand/40",
|
|
||||||
dragState.target?.path === file.path &&
|
dragState.target?.path === file.path &&
|
||||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||||
dragState.files.some(
|
dragState.files.some((f) => f.path === file.path) &&
|
||||||
(f) => f.path === file.path,
|
"opacity-50",
|
||||||
) && "opacity-50",
|
|
||||||
)}
|
)}
|
||||||
title={file.name}
|
title={file.name}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
@@ -1025,15 +861,14 @@ export function FileManagerGrid({
|
|||||||
<div className="relative mb-2 pointer-events-none">
|
<div className="relative mb-2 pointer-events-none">
|
||||||
{getFileIcon(file, viewMode)}
|
{getFileIcon(file, viewMode)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full flex flex-col items-center pointer-events-none">
|
<div className="w-full flex flex-col items-center pointer-events-none">
|
||||||
{editingFile?.path === file.path ? (
|
{editingFile?.path === file.path ? (
|
||||||
<input
|
<input
|
||||||
ref={editInputRef}
|
ref={editInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
setEditingName(e.target.value)
|
|
||||||
}
|
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
onBlur={handleEditConfirm}
|
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"
|
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"
|
||||||
@@ -1068,11 +903,6 @@ export function FileManagerGrid({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10">
|
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border sticky top-0 bg-card z-10">
|
||||||
@@ -1122,31 +952,18 @@ export function FileManagerGrid({
|
|||||||
onCancel={onCancelCreate}
|
onCancel={onCancelCreate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
{files.map((file) => {
|
||||||
className="relative w-full"
|
|
||||||
style={{ height: listVirtualizer.getTotalSize() }}
|
|
||||||
>
|
|
||||||
{listVirtualizer.getVirtualItems().map((vItem) => {
|
|
||||||
const file = files[vItem.index];
|
|
||||||
if (!file) return null;
|
|
||||||
const isSelected = selectedFiles.some(
|
const isSelected = selectedFiles.some(
|
||||||
(f) => f.path === file.path,
|
(f) => f.path === file.path,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={vItem.key}
|
key={file.path}
|
||||||
data-index={vItem.index}
|
|
||||||
ref={listVirtualizer.measureElement}
|
|
||||||
className="absolute top-0 left-0 w-full"
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${vItem.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-file-path={file.path}
|
data-file-path={file.path}
|
||||||
draggable={true}
|
draggable={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors",
|
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 last:border-0 rounded-none select-none transition-colors",
|
||||||
isSelected && "bg-accent-brand/10",
|
isSelected && "bg-accent-brand/10",
|
||||||
dragState.target?.path === file.path &&
|
dragState.target?.path === file.path &&
|
||||||
"bg-accent-brand/20 border-accent-brand border-dashed",
|
"bg-accent-brand/20 border-accent-brand border-dashed",
|
||||||
@@ -1218,11 +1035,9 @@ export function FileManagerGrid({
|
|||||||
{file.permissions || "—"}
|
{file.permissions || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSelecting && selectionRect && (
|
{isSelecting && selectionRect && (
|
||||||
|
|||||||
@@ -71,34 +71,9 @@ 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();
|
void reconcileTransfers();
|
||||||
start();
|
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
||||||
};
|
return () => clearInterval(interval);
|
||||||
|
|
||||||
void reconcileTransfers();
|
|
||||||
if (document.visibilityState !== "hidden") start();
|
|
||||||
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 flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<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="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isEditable && (
|
{isEditable && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ 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,11 +12,6 @@ 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";
|
||||||
|
|
||||||
@@ -343,32 +338,6 @@ 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);
|
||||||
@@ -607,7 +576,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
const syncClipboard = useCallback(() => {
|
const syncClipboard = useCallback(() => {
|
||||||
const client = clientRef.current;
|
const client = clientRef.current;
|
||||||
if (!client || isFirefoxBrowser() || !navigator.clipboard?.readText) return;
|
if (!client || !navigator.clipboard?.readText) return;
|
||||||
navigator.clipboard
|
navigator.clipboard
|
||||||
.readText()
|
.readText()
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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