Compare commits

..

21 Commits

Author SHA1 Message Date
Luke Gustafson 1aea3603a7 Delete .github/workflows/donation-goal.yml 2026-07-13 20:04:16 -05:00
Luke Gustafson 972e27eb64 Remove donation badge from README
Removed donation badge from README.
2026-07-13 20:03:42 -05:00
ZacharyZcR 38e128bd16 Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050)
This reverts commit 0712fdd731.
2026-07-14 01:36:12 +08:00
Sankeerth Nara 0712fdd731 feat: add Open File Manager to tab right-click menu (#1046) 2026-07-14 01:16:11 +08:00
LukeGus bb5559c696 chore: donation bar reporting wrong result 2026-07-06 15:22:13 -05:00
LukeGus b2ff35e106 chore: donation goal generator incorrect docs url usage 2026-07-01 00:41:45 -05:00
LukeGus 19a2cb8eed chore: donation goal generator syntax error 2026-07-01 00:36:45 -05:00
LukeGus 078e6d5de0 chore: improve donation goal svg generator to include stablecoins 2026-06-30 22:41:32 -05:00
Luke Gustafson 794714368a Add Rack Genius logo to README
Added Rack Genius logo to the README.
2026-06-30 13:32:22 -05:00
LukeGus 4fbaf50e08 chore: remove unused donation badge svg from main 2026-06-29 15:16:16 -05:00
LukeGus 4ec4cfcdb7 fix: point donation badge to badges branch 2026-06-29 15:14:29 -05:00
LukeGus a46f2f1343 fix: escape < character in donation SVG 2026-06-29 15:11:29 -05:00
LukeGus 72e5ff5a64 chore: debug donation badge commit step 2026-06-29 15:07:34 -05:00
LukeGus 63cfdfda9e chore: remove unneeded token from donation badge workflow 2026-06-29 15:01:06 -05:00
LukeGus 3a3b51d1ae chore: move donation badge to badges branch to avoid ruleset conflicts 2026-06-29 14:59:29 -05:00
LukeGus 3f4280c2ff Merge remote-tracking branch 'origin/main' 2026-06-29 14:52:33 -05:00
LukeGus 97124bc3b6 fix: svg donation generator push fail 2026-06-29 14:52:14 -05:00
Luke Gustafson 253be0077e Update termix.rb 2026-06-29 14:45:46 -05:00
LukeGus fe96e68872 fix: svg donation generator push fail 2026-06-29 14:26:30 -05:00
LukeGus 30acbed1a7 fix: svg donation generator push fail 2026-06-29 14:05:47 -05:00
LukeGus 7416734f68 chore: fix release workflow to merge docs branch 2026-06-29 13:34:00 -05:00
152 changed files with 2076 additions and 7619 deletions
+1 -14
View File
@@ -14,10 +14,6 @@ on:
options:
- Development
- Production
source_ref:
description: "Git ref/SHA to build (defaults to the workflow ref)"
required: false
default: ""
workflow_call:
inputs:
version:
@@ -33,11 +29,6 @@ on:
required: false
type: boolean
default: false
source_ref:
description: "Git ref/SHA to build"
required: false
type: string
default: ""
jobs:
build:
@@ -46,12 +37,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Resolve source revision
run: echo "SOURCE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
@@ -111,7 +98,7 @@ jobs:
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
labels: |
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 }}
cache-from: type=gha
cache-to: type=gha,mode=max
-181
View File
@@ -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 = '&lt; $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
-16
View File
@@ -23,10 +23,6 @@ on:
- file
- release
- submit
source_ref:
description: "Git ref/SHA to build (defaults to the workflow ref)"
required: false
default: ""
workflow_call:
inputs:
build_type:
@@ -42,11 +38,6 @@ on:
required: false
type: string
default: ""
source_ref:
description: "Git ref/SHA to build"
required: false
type: string
default: ""
outputs:
macos_universal_dmg_sha256:
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
@@ -63,7 +54,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -154,7 +144,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -365,7 +354,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
@@ -592,7 +580,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -699,7 +686,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -837,7 +823,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Get version from package.json
@@ -948,7 +933,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.source_ref || github.ref }}
fetch-depth: 1
- name: Setup Node.js
+2 -5
View File
@@ -278,7 +278,7 @@ jobs:
MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
echo "build_ref=$MAIN_SHA" >> "$GITHUB_OUTPUT"
echo "build_ref=main" >> "$GITHUB_OUTPUT"
docker:
needs: [prep, merge-to-main]
@@ -287,7 +287,6 @@ jobs:
version: ${{ needs.prep.outputs.version }}
build_type: Production
dry_run: ${{ inputs.mode == 'Dry run' }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
create-release:
@@ -352,17 +351,15 @@ jobs:
build_type: all
artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
release_tag: ${{ needs.prep.outputs.release_tag }}
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
electron-submit:
needs: [prep, merge-to-main, electron-release]
needs: [prep, electron-release]
if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
uses: ./.github/workflows/electron.yml
with:
build_type: all
artifact_destination: submit
source_ref: ${{ needs.merge-to-main.outputs.build_ref }}
secrets: inherit
cask-commit-back:
-13
View File
@@ -35,8 +35,6 @@
Termix is free and open source. If you find it useful, consider [donating](https://donate.termix.site/) to help cover server costs and development time.
<a href="https://donate.termix.site/"><img src="https://raw.githubusercontent.com/Termix-SSH/Termix/badges/donation-goal.svg" alt="Monthly donation goal" /></a>
<br />
<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />
@@ -266,8 +264,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -279,8 +275,6 @@ services:
restart: unless-stopped
ports:
- "4822:4822"
volumes:
- termix-data:/termix-data
networks:
- termix-net
@@ -293,13 +287,6 @@ networks:
driver: bridge
```
Termix records enabled SSH, RDP, VNC, and Telnet sessions for replay and audit.
When using an external `guacd`, mount the same recording storage into both
services and set `GUACD_RECORDING_PATH` to the path visible to `guacd` and
`GUACD_RECORDING_BACKEND_PATH` to the corresponding path visible to Termix.
Administrators can change the default 30-day retention period from Session Logs
or with `SESSION_RECORDING_RETENTION_DAYS`.
<br />
## Donate
+3 -3
View File
@@ -1,5 +1,5 @@
# Stage 1: Install dependencies
FROM node:26-slim AS deps
FROM node:24-slim AS deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -36,7 +36,7 @@ RUN npm rebuild better-sqlite3
RUN npm run build:backend
# Stage 4: Production dependencies only
FROM node:26-slim AS production-deps
FROM node:24-slim AS production-deps
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
@@ -53,7 +53,7 @@ RUN npm ci --omit=dev --ignore-scripts && \
npm cache clean --force
# Stage 5: Final optimized image
FROM node:26-slim
FROM node:24-slim
WORKDIR /app
ENV DATA_DIR=/app/data \
-4
View File
@@ -12,8 +12,6 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd-dev
networks:
@@ -23,8 +21,6 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
-3
View File
@@ -10,7 +10,6 @@ services:
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
depends_on:
- guacd
networks:
@@ -20,8 +19,6 @@ services:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+4 -16
View File
@@ -235,18 +235,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/proxmox(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -445,8 +433,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -688,8 +676,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
+4 -4
View File
@@ -434,8 +434,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
@@ -677,8 +677,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
+1 -1
View File
@@ -1397,7 +1397,7 @@ ipcMain.handle(
server.once("error", fail);
server.listen(callbackPort, "localhost", async () => {
server.listen(callbackPort, "127.0.0.1", async () => {
try {
await shell.openExternal(authUrl);
} catch (error) {
+570 -603
View File
File diff suppressed because it is too large Load Diff
+30 -31
View File
@@ -14,7 +14,7 @@
"format:check": "prettier --check .",
"biome:check": "biome check biome.json package.json",
"biome:fix": "biome check --write biome.json package.json",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && 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",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
@@ -45,9 +45,8 @@
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.6",
"@types/ldapjs": "^3.0.6",
"axios": "^1.18.1",
"axios": "^1.18.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"body-parser": "^2.3.0",
@@ -59,28 +58,28 @@
"express": "^5.2.1",
"guacamole-lite": "^1.2.0",
"jose": "^6.2.2",
"js-yaml": "^5.2.1",
"js-yaml": "^5.0.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ldapjs": "^3.0.7",
"motion": "^12.42.2",
"motion": "^12.38.0",
"multer": "^2.2.0",
"nanoid": "^5.1.16",
"nanoid": "^5.1.15",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.7.0",
"undici": "^8.5.0",
"ws": "^8.20.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.2",
"@biomejs/biome": "2.5.1",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.5",
"@codemirror/view": "^6.43.1",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -92,23 +91,23 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.15",
"@radix-ui/react-alert-dialog": "^1.1.18",
"@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-dialog": "^1.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.19",
"@radix-ui/react-label": "^2.1.11",
"@radix-ui/react-popover": "^1.1.18",
"@radix-ui/react-progress": "^1.1.11",
"@radix-ui/react-scroll-area": "^1.2.13",
"@radix-ui/react-select": "^2.3.2",
"@radix-ui/react-separator": "^1.1.11",
"@radix-ui/react-slider": "^1.4.2",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-checkbox": "^1.3.4",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-label": "^2.1.9",
"@radix-ui/react-popover": "^1.1.16",
"@radix-ui/react-progress": "^1.1.9",
"@radix-ui/react-scroll-area": "^1.2.11",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slider": "^1.4.1",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.2",
"@radix-ui/react-tabs": "^1.1.16",
"@radix-ui/react-tooltip": "^1.2.11",
"@tailwindcss/vite": "^4.3.2",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tailwindcss/vite": "^4.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -131,7 +130,7 @@
"@uiw/codemirror-extensions-langs": "^4.25.9",
"@uiw/codemirror-theme-github": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"@xterm/addon-clipboard": "^0.2.0",
@@ -144,7 +143,7 @@
"cmdk": "^1.1.1",
"concurrently": "^10.0.3",
"cytoscape": "^3.34.0",
"electron": "^43.0.0",
"electron": "^42.4.1",
"electron-builder": "^26.15.3",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -153,13 +152,13 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
"i18next": "^26.3.4",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
"radix-ui": "^1.6.1",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
@@ -173,7 +172,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10",
"remark-gfm": "^4.0.1",
"sharp": "^0.35.3",
"sharp": "^0.35.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
+10 -37
View File
@@ -26,31 +26,10 @@ if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
// versions Termix can handle, and conservatively answer future 1.x versions as
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
// claiming support for unknown instructions.
const oldVersionBlock =
" if (version === '1_0_0' || version === '1_1_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const oldPatchedVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const newVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
" protocolVersion = '1_5_0';\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
// Patch 1: version acceptance list
const oldVersionCheck = "if (version === '1_0_0' || version === '1_1_0') {";
const newVersionCheck =
"if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {";
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
const oldTimezone = "if (protocolVersion === '1_1_0') {";
@@ -126,23 +105,17 @@ const newReadyHandler =
let patched = false;
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldPatchedVersionBlock,
newVersionBlock,
);
} else if (guacdClientContent.includes(oldVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldVersionBlock,
newVersionBlock,
);
} else {
if (!guacdClientContent.includes(newVersionCheck)) {
if (!guacdClientContent.includes(oldVersionCheck)) {
console.log(
"[patch-guacamole-lite] Version check target not found, skipping",
);
process.exit(0);
}
guacdClientContent = guacdClientContent.replace(
oldVersionCheck,
newVersionCheck,
);
patched = true;
}
+1 -69
View File
@@ -1,29 +1,6 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const GuacdClient = require("../node_modules/guacamole-lite/lib/GuacdClient.js");
type PatchedGuacdClient = {
connectionSettings: Record<string, unknown>;
nextArgumentStreamIndex: number;
sendInstruction: ReturnType<typeof vi.fn>;
sendHandshakeReply: (serverHandshake: string[]) => void;
sendRequiredArguments: (params: string[]) => void;
};
function createPatchedClient(
connectionSettings: Record<string, unknown>,
): PatchedGuacdClient {
return Object.assign(Object.create(GuacdClient.prototype), {
connectionSettings,
logger: { log: vi.fn() },
nextArgumentStreamIndex: 0,
sendInstruction: vi.fn(),
});
}
import { describe, expect, it } from "vitest";
describe("patch-guacamole-lite", () => {
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(['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],
]);
});
});
-85
View File
@@ -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}`);
}
+5 -42
View File
@@ -460,8 +460,6 @@ async function initializeCompleteDatabase(): Promise<void> {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
@@ -566,30 +564,12 @@ async function initializeCompleteDatabase(): Promise<void> {
`);
try {
const timeoutRow = sqlite
.prepare(
"SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
)
.get() as { value: string } | undefined;
const timeoutMinutes = timeoutRow
? parseInt(timeoutRow.value, 10)
: 30;
const ttlMs =
!isNaN(timeoutMinutes) && timeoutMinutes > 0
? timeoutMinutes * 60_000
: 30 * 60_000;
const cutoff = new Date(Date.now() - ttlMs).toISOString();
const result = sqlite
.prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
.run(cutoff);
if (result.changes > 0) {
databaseLogger.info("Expired open tabs cleared on startup", {
operation: "db_init_open_tabs_cleanup",
count: result.changes,
});
}
sqlite.prepare("DELETE FROM user_open_tabs").run();
databaseLogger.info("Open tabs cleared on startup", {
operation: "db_init_open_tabs_cleanup",
});
} 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",
error: e,
});
@@ -715,17 +695,6 @@ const addColumnIfNotExists = (
};
const migrateSchema = () => {
addColumnIfNotExists(
"session_recordings",
"protocol",
"TEXT NOT NULL DEFAULT 'ssh'",
);
addColumnIfNotExists(
"session_recordings",
"format",
"TEXT NOT NULL DEFAULT 'text'",
);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -778,10 +747,6 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY,
@@ -1588,8 +1553,6 @@ const migrateSchema = () => {
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
-5
View File
@@ -54,9 +54,6 @@ export const sessions = sqliteTable("sessions", {
jwtToken: text("jwt_token").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -660,8 +657,6 @@ export const sessionRecordings = sqliteTable("session_recordings", {
dangerousActions: text("dangerous_actions"),
recordingPath: text("recording_path"),
protocol: text("protocol").notNull().default("ssh"),
format: text("format").notNull().default("text"),
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
.default(false),
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { and, asc, eq } from "drizzle-orm";
import { dashboardLogger } from "../../utils/logger.js";
import { db, DatabaseSaveTrigger } from "../db/index.js";
import { db } from "../db/index.js";
import { dashboardServiceLinks } from "../db/schema.js";
import { isNonEmptyString } from "./host-normalizers.js";
import {
@@ -107,7 +107,6 @@ dashboardServiceLinksRouter.post("/", async (req: Request, res: Response) => {
})
.returning();
DatabaseSaveTrigger.triggerSave("dashboard_service_link_created");
res.status(201).json(created);
} catch (err) {
dashboardLogger.error("Failed to create service link", err);
@@ -176,7 +175,6 @@ dashboardServiceLinksRouter.delete(
),
);
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
} catch (err) {
dashboardLogger.error("Failed to delete service link", err);
@@ -274,7 +272,6 @@ dashboardServiceLinksRouter.put("/:id", async (req: Request, res: Response) => {
)
.returning();
DatabaseSaveTrigger.triggerSave("dashboard_service_link_updated");
res.json(updated);
} catch (err) {
dashboardLogger.error("Failed to update service link", err);
@@ -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();
}
+8 -135
View File
@@ -25,10 +25,7 @@ import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../ssh/credential-username.js";
import { pickResolvedUsername } from "../../ssh/credential-username.js";
import {
isNonEmptyString,
isValidPort,
@@ -43,10 +40,6 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
} from "./host-enrollment-auth.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const router = express.Router();
@@ -168,10 +161,9 @@ registerHostInternalRoutes(router);
* description: Failed to save SSH data.
*/
router.post(
["/db/host", "/enroll"],
"/db/host",
authenticateJWT,
requireDataAccess,
requireHostEnrollmentAccessForPath,
upload.single("key"),
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -204,10 +196,6 @@ router.post(
hostData = req.body;
}
if (req.path === "/enroll") {
hostData = applyHostEnrollmentDefaults(hostData);
}
const {
connectionType,
name,
@@ -271,8 +259,6 @@ router.post(
rdpPort,
vncPort,
telnetPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
@@ -282,8 +268,6 @@ router.post(
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
@@ -401,11 +385,6 @@ router.post(
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
telnetPort: telnetPort || 23,
rdpAuthType: enableRdp ? rdpAuthType || null : null,
rdpCredentialId:
enableRdp && rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
@@ -416,11 +395,6 @@ router.post(
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
telnetCredentialId:
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
@@ -459,12 +433,7 @@ router.post(
sshDataObj.key = key || null;
sshDataObj.keyPassword = keyPassword || null;
sshDataObj.keyType = keyType;
sshDataObj.password = password || null;
} else if (effectiveAuthType === "credential") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.password = null;
} else if (effectiveAuthType === "agent") {
sshDataObj.password = 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
* /host/quick-connect:
@@ -734,7 +642,7 @@ router.post(
const cred = credentials[0];
resolvedPassword = pickResolvedPassword(password, cred.password);
resolvedPassword = cred.password as string | undefined;
resolvedKey = cred.privateKey as string | undefined;
resolvedKeyPassword = cred.keyPassword as string | undefined;
resolvedKeyType = cred.keyType as string | undefined;
@@ -927,8 +835,6 @@ router.put(
rdpPort,
vncPort,
telnetPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
@@ -938,8 +844,6 @@ router.put(
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
@@ -1054,11 +958,6 @@ router.put(
rdpPort: rdpPort || 3389,
vncPort: vncPort || 5900,
telnetPort: telnetPort || 23,
rdpAuthType: enableRdp ? rdpAuthType || null : null,
rdpCredentialId:
enableRdp && rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
rdpSecurity: rdpSecurity || null,
@@ -1069,11 +968,6 @@ router.put(
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: enableTelnet ? telnetAuthType || null : null,
telnetCredentialId:
enableTelnet && telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
@@ -1121,12 +1015,7 @@ router.put(
if (keyType) {
sshDataObj.keyType = keyType;
}
sshDataObj.password = password || null;
} else if (effectiveAuthType === "credential") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.password = null;
} else if (effectiveAuthType === "agent") {
sshDataObj.password = null;
sshDataObj.key = null;
@@ -1176,7 +1065,6 @@ router.put(
credentialId: hosts.credentialId,
rdpCredentialId: hosts.rdpCredentialId,
vncCredentialId: hosts.vncCredentialId,
telnetCredentialId: hosts.telnetCredentialId,
authType: hosts.authType,
})
.from(hosts)
@@ -1227,20 +1115,12 @@ router.put(
sshDataObj.vncCredentialId !== undefined
? sshDataObj.vncCredentialId
: hostRecord[0].vncCredentialId;
const newTelnetCredId =
sshDataObj.telnetCredentialId !== undefined
? sshDataObj.telnetCredentialId
: hostRecord[0].telnetCredentialId;
const hadCredential =
hostRecord[0].credentialId !== null ||
hostRecord[0].rdpCredentialId !== null ||
hostRecord[0].vncCredentialId !== null ||
hostRecord[0].telnetCredentialId !== null;
hostRecord[0].vncCredentialId !== null;
const willHaveCredential =
newCredId !== null ||
newRdpCredId !== null ||
newVncCredId !== null ||
newTelnetCredId !== null;
newCredId !== null || newRdpCredId !== null || newVncCredId !== null;
if (hadCredential && !willHaveCredential) {
await db
.delete(hostAccess)
@@ -1428,7 +1308,6 @@ router.get(
rdpPort: hosts.rdpPort,
vncPort: hosts.vncPort,
telnetPort: hosts.telnetPort,
rdpAuthType: hosts.rdpAuthType,
rdpCredentialId: hosts.rdpCredentialId,
rdpUser: hosts.rdpUser,
rdpPassword: hosts.rdpPassword,
@@ -1439,8 +1318,6 @@ router.get(
vncCredentialId: hosts.vncCredentialId,
vncUser: hosts.vncUser,
vncPassword: hosts.vncPassword,
telnetAuthType: hosts.telnetAuthType,
telnetCredentialId: hosts.telnetCredentialId,
telnetUser: hosts.telnetUser,
telnetPassword: hosts.telnetPassword,
@@ -1783,8 +1660,6 @@ router.get(
rdpPort: resolvedHost.rdpPort || 3389,
vncPort: resolvedHost.vncPort || 5900,
telnetPort: resolvedHost.telnetPort || 23,
rdpAuthType: resolvedHost.rdpAuthType || null,
rdpCredentialId: resolvedHost.rdpCredentialId || null,
rdpUser: resolvedHost.rdpUser || null,
rdpPassword: resolvedHost.rdpPassword || null,
rdpDomain: resolvedHost.rdpDomain || null,
@@ -1794,8 +1669,6 @@ router.get(
vncCredentialId: resolvedHost.vncCredentialId || null,
vncUser: resolvedHost.vncUser || null,
vncPassword: resolvedHost.vncPassword || null,
telnetAuthType: resolvedHost.telnetAuthType || null,
telnetCredentialId: resolvedHost.telnetCredentialId || null,
telnetUser: resolvedHost.telnetUser || null,
telnetPassword: resolvedHost.telnetPassword || null,
guacamoleConfig: resolvedHost.guacamoleConfig
@@ -2572,7 +2445,7 @@ async function resolveHostCredentials(
const credential = credentials[0];
const resolvedHost: Record<string, unknown> = {
...host,
password: pickResolvedPassword(host.password, credential.password),
password: credential.password,
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
+33 -118
View File
@@ -2,58 +2,24 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import fs from "fs";
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 { sessionRecordings, hosts, users } from "../db/schema.js";
import { sessionRecordings, hosts } from "../db/schema.js";
import { apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import type { Request, Response } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
const router = express.Router();
const DATA_DIR = process.env.DATA_DIR ?? "./db/data";
const permissionManager = PermissionManager.getInstance();
function isAllowedRecordingPath(filePath: string): boolean {
const resolved = path.resolve(filePath);
return ["session_logs", "session_recordings"].some((directory) => {
const base = `${path.resolve(DATA_DIR, directory)}${path.sep}`;
return resolved.startsWith(base);
});
}
function getRetentionDays(): number {
const envDays = parseInt(
process.env.SESSION_RECORDING_RETENTION_DAYS || "",
10,
);
try {
const row = db.$client
.prepare(
"SELECT value FROM settings WHERE key = 'session_recording_retention_days'",
)
.get() as { value?: string } | undefined;
const configured = parseInt(row?.value || "", 10);
if (configured >= 1 && configured <= 3650) return configured;
} catch {
// use environment/default below
}
return envDays >= 1 && envDays <= 3650 ? envDays : 30;
}
async function canAccessRecording(
userId: string,
ownerId: string,
): Promise<boolean> {
return userId === ownerId || permissionManager.isAdmin(userId);
}
// Delete session log files and DB rows older than this many days
const LOG_RETENTION_DAYS = 30;
async function pruneOldLogs(): Promise<void> {
try {
const cutoff = new Date(
Date.now() - getRetentionDays() * 24 * 60 * 60 * 1000,
Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000,
).toISOString();
const old = await db
@@ -67,7 +33,8 @@ async function pruneOldLogs(): Promise<void> {
for (const row of old) {
if (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(() => {});
}
}
@@ -114,7 +81,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const isAdmin = await permissionManager.isAdmin(userId);
const rows = await db
.select({
id: sessionRecordings.id,
@@ -124,16 +90,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
endedAt: sessionRecordings.endedAt,
duration: sessionRecordings.duration,
recordingPath: sessionRecordings.recordingPath,
protocol: sessionRecordings.protocol,
format: sessionRecordings.format,
username: users.username,
hostName: hosts.name,
hostIp: hosts.ip,
})
.from(sessionRecordings)
.leftJoin(hosts, eq(sessionRecordings.hostId, hosts.id))
.leftJoin(users, eq(sessionRecordings.userId, users.id))
.where(isAdmin ? undefined : eq(sessionRecordings.userId, userId))
.where(eq(sessionRecordings.userId, userId))
.orderBy(desc(sessionRecordings.startedAt));
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
* /session_logs/{id}:
@@ -231,13 +153,12 @@ router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const rows = await db
.select()
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
res.json({ log: rows[0] });
} catch (error) {
apiLogger.error("Failed to fetch session log", error, {
@@ -285,27 +206,26 @@ router.get(
try {
const rows = await db
.select({
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
format: sessionRecordings.format,
})
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.where(
and(
eq(sessionRecordings.id, id),
eq(sessionRecordings.userId, userId),
),
)
.limit(1);
if (rows.length === 0)
return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
const filePath = rows[0].recordingPath;
if (!filePath)
return res.status(404).json({ error: "No recording file" });
const resolvedPath = path.resolve(filePath);
if (!isAllowedRecordingPath(resolvedPath)) {
const allowedBase = path.resolve(DATA_DIR, "session_logs");
if (!resolvedPath.startsWith(allowedBase)) {
return res.status(403).json({ error: "Forbidden" });
}
@@ -313,14 +233,8 @@ router.get(
return res.status(404).json({ error: "File not found" });
}
const content = await fs.promises.readFile(resolvedPath);
const contentType =
rows[0].format === "guacamole"
? "application/vnd.apache.guacamole.recording"
: rows[0].format === "asciicast"
? "application/x-asciicast"
: "text/plain";
res.type(contentType).send(content);
const content = await fs.promises.readFile(resolvedPath, "utf-8");
res.type("text/plain").send(content);
} catch (error) {
apiLogger.error("Failed to read session log content", error, {
operation: "session_log_content",
@@ -363,26 +277,27 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
try {
const rows = await db
.select({
recordingPath: sessionRecordings.recordingPath,
userId: sessionRecordings.userId,
})
.select({ recordingPath: sessionRecordings.recordingPath })
.from(sessionRecordings)
.where(eq(sessionRecordings.id, id))
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
)
.limit(1);
if (rows.length === 0) return res.status(404).json({ error: "Not found" });
if (!(await canAccessRecording(userId, rows[0].userId))) {
return res.status(403).json({ error: "Forbidden" });
}
const filePath = rows[0].recordingPath;
await db.delete(sessionRecordings).where(eq(sessionRecordings.id, id));
await db
.delete(sessionRecordings)
.where(
and(eq(sessionRecordings.id, id), eq(sessionRecordings.userId, userId)),
);
if (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(() => {});
}
}
@@ -11,15 +11,8 @@ vi.mock("../../utils/logger.js", () => ({
},
}));
const {
isOIDCUserAllowed,
getOIDCConfigFromEnv,
extractOidcGroups,
validateLogoutTokenClaims,
} = await import("./user-oidc-utils.js");
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
const { isOIDCUserAllowed, getOIDCConfigFromEnv, extractOidcGroups } =
await import("./user-oidc-utils.js");
describe("isOIDCUserAllowed", () => {
it("allows everyone when the allow-list is empty", () => {
@@ -206,48 +199,3 @@ describe("extractOidcGroups", () => {
expect(extractOidcGroups({})).toEqual([]);
});
});
describe("validateLogoutTokenClaims", () => {
const validClaims = {
sub: "subject-1",
sid: "session-1",
iat: 1_783_641_600,
jti: "logout-1",
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
};
it("accepts a spec-compliant back-channel logout payload", () => {
expect(validateLogoutTokenClaims(validClaims)).toEqual({
sub: "subject-1",
sid: "session-1",
jti: "logout-1",
});
});
it("requires the logout event to contain an object", () => {
expect(() =>
validateLogoutTokenClaims({
...validClaims,
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
}),
).toThrow("missing back-channel logout event");
});
it("requires iat and jti claims", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
).toThrow("missing iat claim");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
).toThrow("missing jti claim");
});
it("rejects nonce and requires sub or sid", () => {
expect(() =>
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
).toThrow("must not contain a nonce");
expect(() =>
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
).toThrow("must contain sub and/or sid");
});
});
@@ -6,13 +6,6 @@ import { eq } from "drizzle-orm";
import { DataCrypto } from "../../utils/data-crypto.js";
import { Agent } from "undici";
const BACKCHANNEL_LOGOUT_EVENT =
"http://schemas.openid.net/event/backchannel-logout";
function normalizeIssuer(url: string): string {
return url.trim().replace(/\/+$/, "");
}
export type OIDCConfig = {
client_id: string;
client_secret: string;
@@ -424,102 +417,3 @@ export async function loadProviderConfig(
return null;
}
export async function resolveProviderByIssuer(issuer: string): Promise<{
config: OIDCConfig;
providerType: SSOProviderType;
providerDbId: number | null;
} | null> {
const target = normalizeIssuer(issuer);
try {
const rows = await db
.select()
.from(ssoProviders)
.where(eq(ssoProviders.enabled, true));
for (const row of rows) {
if (!["oidc", "github", "google"].includes(row.type)) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(row.config);
} catch {
continue;
}
parsed = decryptConfigSecret(parsed);
const providerType = row.type as SSOProviderType;
const config = applyProviderDefaults(
parsed as unknown as OIDCConfig,
providerType,
);
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
return { config, providerType, providerDbId: row.id };
}
}
} catch (err) {
authLogger.error("Failed to resolve SSO provider by issuer", err, {
issuer,
});
}
const envConfig = getOIDCConfigFromEnv();
if (
envConfig?.issuer_url &&
normalizeIssuer(envConfig.issuer_url) === target
) {
return { config: envConfig, providerType: "oidc", providerDbId: null };
}
return null;
}
export type LogoutTokenClaims = {
sub: string | null;
sid: string | null;
jti: string;
};
export function validateLogoutTokenClaims(
payload: Record<string, unknown>,
): LogoutTokenClaims {
if ("nonce" in payload) {
throw new Error("logout_token must not contain a nonce claim");
}
const event = (payload.events as Record<string, unknown> | undefined)?.[
BACKCHANNEL_LOGOUT_EVENT
];
if (!event || typeof event !== "object" || Array.isArray(event)) {
throw new Error("logout_token missing back-channel logout event");
}
if (!Number.isInteger(payload.iat)) {
throw new Error("logout_token missing iat claim");
}
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
if (!jti) {
throw new Error("logout_token missing jti claim");
}
const sub = typeof payload.sub === "string" ? payload.sub : null;
const sid = typeof payload.sid === "string" ? payload.sid : null;
if (!sub && !sid) {
throw new Error("logout_token must contain sub and/or sid");
}
return { sub, sid, jti };
}
export async function validateLogoutToken(
logoutToken: string,
config: OIDCConfig,
): Promise<LogoutTokenClaims> {
const payload = await verifyOIDCToken(
logoutToken,
config.issuer_url,
config.client_id,
config.ca_cert,
);
return validateLogoutTokenClaims(payload);
}
+7 -96
View File
@@ -18,10 +18,6 @@ import {
getRequestBasePath,
getRequestBaseUrlWithForceHTTPS,
} from "../../utils/request-origin.js";
import {
getDesktopOidcCallbackUrl,
isOidcTokenCallback,
} from "../../utils/oidc-desktop-callback.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import {
getOIDCConfigFromEnv,
@@ -30,8 +26,6 @@ import {
extractOidcGroups,
loadProviderConfig,
buildFetchOptions,
resolveProviderByIssuer,
validateLogoutToken,
} from "./user-oidc-utils.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
@@ -658,10 +652,7 @@ router.get("/oidc/authorize", async (req, res) => {
const referer = req.get("Referer");
let frontendOrigin;
if (desktopCallbackPort) {
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
if (!frontendOrigin) {
return res.status(400).json({ error: "Invalid desktop callback port" });
}
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
let callbackUrl: URL;
try {
@@ -1008,7 +999,9 @@ router.get("/oidc/callback", async (req, res) => {
});
const ghRedirectUrl = new URL(frontendOrigin);
ghRedirectUrl.searchParams.set("success", "true");
const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin);
const ghIsTokenCallback =
frontendOrigin.startsWith("http://127.0.0.1:") ||
frontendOrigin.startsWith("termix-mobile:");
const ghMaxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
@@ -1471,16 +1464,10 @@ router.get("/oidc/callback", async (req, res) => {
"oidc_role_shared_credentials",
);
const oidcSub = typeof userInfo.sub === "string" ? userInfo.sub : null;
const oidcSid = typeof userInfo.sid === "string" ? userInfo.sid : null;
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
rememberMe: storedRememberMe,
oidcSub,
oidcSid,
ssoProviderId: callbackProviderDbId ?? null,
});
authLogger.success("OIDC login successful", {
@@ -1492,7 +1479,9 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("success", "true");
const isTokenCallback = isOidcTokenCallback(frontendOrigin);
const isTokenCallback =
frontendOrigin.startsWith("http://127.0.0.1:") ||
frontendOrigin.startsWith("termix-mobile:");
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
@@ -1799,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
* /users/me:
+23 -80
View File
@@ -3,10 +3,6 @@ import { guacLogger } from "../utils/logger.js";
import { GuacamoleTokenService } from "./token-service.js";
import { getDb } from "../database/db/index.js";
import { resolveGuacdOptions } from "../utils/guacd-config.js";
import fs from "fs";
import path from "path";
import { sessionRecordings } from "../database/db/schema.js";
import type { GuacamoleRecordingMetadata } from "./token-service.js";
const tokenService = GuacamoleTokenService.getInstance();
@@ -25,63 +21,6 @@ function readGuacdOptions(): { host: string; port: number } {
}
const GUAC_WS_PORT = 30008;
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
connectionSettings?: {
connection?: { type?: string };
recording?: GuacamoleRecordingMetadata;
};
};
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
const recording = clientConnection.connectionSettings?.recording;
if (!recording) return;
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
if (!resolvedPath.startsWith(allowedBase)) return;
// guacd may flush/rename the recording just after the websocket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
});
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await getDb()
.insert(sessionRecordings)
.values({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
});
}
const websocketOptions = {
port: GUAC_WS_PORT,
@@ -150,31 +89,35 @@ function createGuacServer(): GuacamoleLite {
clientOptions,
);
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
server.on(
"open",
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.type,
});
});
});
},
);
server.on(
"close",
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.type,
});
},
);
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
(
clientConnection: { connectionSettings?: Record<string, unknown> },
error: Error,
) => {
guacLogger.error("Guacamole connection error", error, {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
type: clientConnection.connectionSettings?.type,
});
},
);
+21 -59
View File
@@ -9,15 +9,12 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { Client } from "ssh2";
import net from "net";
import crypto from "crypto";
import path from "path";
import type { AuthenticatedRequest } from "../../types/index.js";
import { resolveGuacdOptions } from "../utils/guacd-config.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
const authManager = AuthManager.getInstance();
const DATA_DIR = process.env.DATA_DIR || "./db/data";
router.use(authManager.createAuthMiddleware());
@@ -529,28 +526,6 @@ router.post(
? { guacdPort: perConnectionGuacdPort }
: {}),
};
const recordingEnabled = host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.resolve(DATA_DIR, "session_recordings", "guacamole");
const recordingMetadata = recordingEnabled
? {
hostId,
userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
path: recordingName,
startedAt: new Date().toISOString(),
}
: undefined;
if (recordingEnabled) {
guacConfig["recording-path"] = recordingPath;
guacConfig["recording-name"] = recordingName;
guacConfig["create-recording-path"] = true;
guacConfig["recording-exclude-output"] = false;
guacConfig["recording-include-keys"] = true;
}
switch (connectionType) {
case "rdp":
@@ -558,28 +533,22 @@ router.post(
guacConfig["drive-path"] = "/drive";
guacConfig["create-drive-path"] = true;
}
token = tokenService.createRdpToken(
hostname,
username,
password,
{
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
"ignore-cert":
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
token = tokenService.createRdpToken(hostname, username, password, {
port,
domain,
security:
(host.rdpSecurity as string) ||
(host.security as string) ||
undefined,
"ignore-cert":
host.rdpIgnoreCert !== undefined
? !!host.rdpIgnoreCert
: host.ignoreCert !== undefined
? !!host.ignoreCert
: true,
...guacConfig,
...guacdOverrides,
});
break;
case "vnc":
token = tokenService.createVncToken(
@@ -592,21 +561,14 @@ router.post(
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
break;
case "telnet":
token = tokenService.createTelnetToken(
hostname,
username,
password,
{
port,
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
);
token = tokenService.createTelnetToken(hostname, username, password, {
port,
...guacConfig,
...guacdOverrides,
});
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
@@ -45,23 +45,4 @@ describe("GuacamoleTokenService", () => {
});
expect(decrypted?.connection.settings["disable-auth"]).toBeUndefined();
});
it("preserves recording metadata outside guacd connection settings", () => {
const recording = {
hostId: 42,
userId: "user-1",
protocol: "vnc" as const,
path: "recording.guac",
startedAt: "2026-07-14T00:00:00.000Z",
};
const token = tokenService.createVncToken(
"vnc.example.test",
"user",
"secret",
{},
recording,
);
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
});
-15
View File
@@ -30,15 +30,6 @@ export interface GuacamoleConnectionSettings {
export interface GuacamoleToken {
connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata;
}
export interface GuacamoleRecordingMetadata {
hostId: number;
userId: string;
protocol: "rdp" | "vnc" | "telnet";
path: string;
startedAt: string;
}
const CIPHER = "aes-256-cbc";
@@ -136,7 +127,6 @@ export class GuacamoleTokenService {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -154,7 +144,6 @@ export class GuacamoleTokenService {
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
@@ -167,7 +156,6 @@ export class GuacamoleTokenService {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -183,7 +171,6 @@ export class GuacamoleTokenService {
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
@@ -196,7 +183,6 @@ export class GuacamoleTokenService {
guacdHost?: string;
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -212,7 +198,6 @@ export class GuacamoleTokenService {
...settingsOptions,
},
},
recording,
};
return this.encryptToken(token);
}
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
pickResolvedUsername,
pickResolvedPassword,
expandOidcUsername,
} 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", () => {
beforeEach(() => {
vi.resetModules();
-13
View File
@@ -21,19 +21,6 @@ export function pickResolvedUsername(
return cred;
}
/**
* A host can keep a per-host password while using a shared key credential.
* Prefer that host-specific value and fall back to the credential password.
*/
export function pickResolvedPassword(
hostPassword: unknown,
credentialPassword: unknown,
): string | undefined {
if (isNonEmptyString(hostPassword)) return hostPassword;
if (isNonEmptyString(credentialPassword)) return credentialPassword;
return undefined;
}
/**
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
* connecting user's OIDC identifier. Returns the username unchanged if it does
-9
View File
@@ -14,7 +14,6 @@ import {
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
const sshLogger = systemLogger;
@@ -277,10 +276,6 @@ async function createJumpHostChain(
});
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
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) => {
client.on("ready", () => resolve());
client.on("error", reject);
-2
View File
@@ -28,7 +28,6 @@ import {
getRuntimeLabel,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
const sshLogger = logger;
@@ -1618,7 +1617,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
return;
}
} else {
await resolveSshConnectConfigHost(config);
client.connect(config);
}
} catch (error) {
@@ -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,
type SSHSession,
} from "./file-manager-session.js";
import { buildDeleteCommand } from "./file-manager-operation-commands.js";
import { isWindowsSftpPath } from "./transfer-paths.js";
type FileOperationRoutesDeps = {
sshSessions: Record<string, SSHSession>;
@@ -375,10 +375,22 @@ export function registerFileOperationRoutes(
});
sshConn.lastActive = Date.now();
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
itemPath,
Boolean(isDirectory),
);
const isWindowsPath = isWindowsSftpPath(itemPath);
let deleteCommand: string;
if (isWindowsPath) {
const winPath = itemPath
.replace(/\//g, "\\")
.replace(/^\\([A-Za-z]:\\)/, "$1");
const escapedWinPath = winPath.replace(/"/g, '""');
deleteCommand = isDirectory
? `rd /s /q "${escapedWinPath}"`
: `del /f /q "${escapedWinPath}"`;
} else {
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
deleteCommand = isDirectory
? `rm -rf '${escapedPath}'`
: `rm -f '${escapedPath}'`;
}
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
@@ -409,85 +421,89 @@ export function registerFileOperationRoutes(
return;
}
execChannel(sshConn, commandWithSuccess, (err, stream) => {
if (err) {
fileLogger.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
fileLogger.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
execChannel(
sshConn,
`${deleteCommand} && echo "SUCCESS"`,
(err, stream) => {
if (err) {
fileLogger.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
userId,
path: itemPath,
});
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("error", (streamErr) => {
fileLogger.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
fileLogger.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
userId,
path: itemPath,
});
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
},
);
});
};
-3
View File
@@ -43,7 +43,6 @@ import {
} from "./file-manager-session.js";
import { registerFileListingRoutes } from "./file-manager-list-routes.js";
import { registerFileOperationRoutes } from "./file-manager-operation-routes.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
import { registerFileActionRoutes } from "./file-manager-action-routes.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
@@ -195,7 +194,6 @@ async function buildDedicatedTransferConnectConfig(
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
await resolveSshConnectConfigHost(config);
const authType = host.authType;
@@ -1761,7 +1759,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
});
}
} else {
await resolveSshConnectConfigHost(config);
client.connect(config);
}
});
+1 -57
View File
@@ -1,10 +1,8 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
createConnectionLog,
tcpPingThroughJumpHost,
} from "./host-metrics-helpers.js";
describe("supportsMetrics", () => {
@@ -68,57 +66,3 @@ describe("createConnectionLog", () => {
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
-27
View File
@@ -1,5 +1,4 @@
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import type { Client } from "ssh2";
export type StatsCapableHost = {
connectionType?: string;
@@ -22,32 +21,6 @@ export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
}
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
}
export function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
@@ -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();
});
});
-82
View File
@@ -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 metricsCache = new MetricsCache();
export const authFailureTracker = new AuthFailureTracker();
+28 -187
View File
@@ -4,10 +4,7 @@ import { createCorsMiddleware } from "../utils/cors-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "./credential-username.js";
import { pickResolvedUsername } from "./credential-username.js";
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
@@ -41,7 +38,6 @@ import { registerHostMetricsPreferencesRoutes } from "./host-metrics-preferences
import { registerHostMetricsHistoryRoutes } from "./host-metrics-history-routes.js";
import { AlertEngine } from "./alert-engine.js";
import { registerManagerRoutes } from "./managers/index.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "./host-metrics-jump-hosts.js";
@@ -49,7 +45,6 @@ import {
createConnectionLog,
isTcpPingEnabled,
supportsMetrics,
tcpPingThroughJumpHost,
} from "./host-metrics-helpers.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import {
@@ -62,12 +57,9 @@ import {
} from "./host-metrics-sessions.js";
import {
authFailureTracker,
hostPollCache,
metricsCache,
metricsPollLimiter,
pollingBackoff,
requestQueue,
statusPollLimiter,
} from "./host-metrics-state.js";
const authManager = AuthManager.getInstance();
@@ -112,7 +104,6 @@ interface SSHHostWithCredentials {
rdpPort?: number;
vncPort?: number;
telnetPort?: number;
vaultProfile?: { id?: number | null } | null;
}
type StatusEntry = {
@@ -172,9 +163,6 @@ class PollingManager {
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, MetricsViewer>();
private viewerCleanupInterval: NodeJS.Timeout;
/** Skip stacking another status/metrics poll while one is already running. */
private statusInFlight = new Set<number>();
private metricsInFlight = new Set<number>();
constructor() {
this.viewerCleanupInterval = setInterval(() => {
@@ -182,81 +170,6 @@ class PollingManager {
}, 60000);
}
/** Spread timers so N hosts do not fire on the same wall-clock second. */
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
private async resolveHostForPoll(
host: SSHHostWithCredentials,
userId: string,
): Promise<SSHHostWithCredentials | null> {
const cached = hostPollCache.get(
host.id,
userId,
) as SSHHostWithCredentials | null;
if (cached) {
return cached;
}
const refreshed = await fetchHostById(host.id, userId);
if (!refreshed) {
return null;
}
hostPollCache.set(host.id, userId, refreshed);
const config = this.pollingConfigs.get(host.id);
if (config) {
config.host = refreshed;
config.statsConfig = this.parseStatsConfig(refreshed.statsConfig);
}
return refreshed;
}
private scheduleStatusPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.statusInFlight.has(host.id)) {
return;
}
this.statusInFlight.add(host.id);
void statusPollLimiter
.run(() => this.pollHostStatus(host, viewerUserId))
.catch((err) => {
statsLogger.error("Status polling failed", err, {
operation: "status_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.statusInFlight.delete(host.id);
});
}
private scheduleMetricsPoll(
host: SSHHostWithCredentials,
viewerUserId?: string,
): void {
if (this.metricsInFlight.has(host.id)) {
return;
}
this.metricsInFlight.add(host.id);
void metricsPollLimiter
.run(() => this.pollHostMetrics(host, viewerUserId))
.catch((err) => {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => {
this.metricsInFlight.delete(host.id);
});
}
private getGlobalDefaults(): {
statusCheckInterval: number;
metricsInterval: number;
@@ -390,17 +303,14 @@ class PollingManager {
this.pollingConfigs.set(host.id, config);
if (isTcpPingEnabled(statsConfig)) {
const intervalMs = this.intervalWithJitter(
statsConfig.statusCheckInterval * 1000,
host.id,
);
const intervalMs = statsConfig.statusCheckInterval * 1000;
this.scheduleStatusPoll(host, viewerUserId);
this.pollHostStatus(host, viewerUserId);
config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
this.scheduleStatusPoll(latestConfig.host, latestConfig.viewerUserId);
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
} else {
@@ -408,27 +318,9 @@ class PollingManager {
}
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
const intervalMs = this.intervalWithJitter(
statsConfig.metricsInterval * 1000,
host.id,
);
const intervalMs = statsConfig.metricsInterval * 1000;
// First sample still awaited (gated) so callers can rely on a warm cache.
if (!this.metricsInFlight.has(host.id)) {
this.metricsInFlight.add(host.id);
try {
await metricsPollLimiter.run(() =>
this.pollHostMetrics(host, viewerUserId),
);
} catch (err) {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
} finally {
this.metricsInFlight.delete(host.id);
}
}
await this.pollHostMetrics(host, viewerUserId);
config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
@@ -437,10 +329,15 @@ class PollingManager {
latestConfig.statsConfig.metricsEnabled &&
supportsMetrics(latestConfig.host)
) {
this.scheduleMetricsPoll(
this.pollHostMetrics(
latestConfig.host,
latestConfig.viewerUserId,
);
).catch((err) => {
statsLogger.error("Metrics polling failed", err, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
});
}
}, intervalMs);
} else {
@@ -453,51 +350,30 @@ class PollingManager {
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await this.resolveHostForPoll(host, userId);
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) {
return;
}
try {
let pingHost = refreshedHost.ip;
const ct = refreshedHost.connectionType || "ssh";
let pingPort: number;
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
else pingPort = refreshedHost.port;
let isOnline: boolean;
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
const proxyConfig: SOCKS5Config | null =
refreshedHost.useSocks5 &&
(refreshedHost.socks5Host ||
(refreshedHost.socks5ProxyChain &&
refreshedHost.socks5ProxyChain.length > 0))
? {
useSocks5: true,
socks5Host: refreshedHost.socks5Host,
socks5Port: refreshedHost.socks5Port,
socks5Username: refreshedHost.socks5Username,
socks5Password: refreshedHost.socks5Password,
socks5ProxyChain: refreshedHost.socks5ProxyChain,
}
: null;
const jumpClient = await createJumpHostChain(
refreshedHost.jumpHosts,
const firstJump = await fetchHostById(
refreshedHost.jumpHosts[0].hostId,
userId,
proxyConfig,
);
isOnline = jumpClient
? await tcpPingThroughJumpHost(
jumpClient,
refreshedHost.ip,
pingPort,
5000,
)
: false;
} else {
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
if (firstJump) {
pingHost = firstJump.ip;
pingPort = firstJump.port;
}
}
const isOnline = await tcpPing(pingHost, pingPort, 5000);
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: new Date().toISOString(),
@@ -523,7 +399,7 @@ class PollingManager {
viewerUserId?: string,
): Promise<void> {
const userId = viewerUserId || host.userId;
const refreshedHost = await this.resolveHostForPoll(host, userId);
const refreshedHost = await fetchHostById(host.id, userId);
if (!refreshedHost) {
return;
}
@@ -678,9 +554,6 @@ class PollingManager {
this.metricsStore.delete(hostId);
}
}
hostPollCache.invalidate(hostId);
this.statusInFlight.delete(hostId);
this.metricsInFlight.delete(hostId);
}
stopMetricsOnly(hostId: number): void {
@@ -1087,10 +960,9 @@ async function resolveHostCredentials(
host.overrideCredentialUsername,
);
baseHost.password = pickResolvedPassword(
host.password,
credential.password,
);
if (credential.password) {
baseHost.password = credential.password;
}
if (
credential.key ||
(credential as Record<string, unknown>).privateKey
@@ -1256,8 +1128,6 @@ async function buildSshConfig(
// no credentials needed
} else if (host.authType === "opkssh") {
// cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "vault") {
// cert auth setup happens in createSshFactory (needs client instance)
} else if (host.authType === "credential") {
if (host.password) {
base.password = host.password;
@@ -1316,10 +1186,6 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
}
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("./vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
const proxyConfig: SOCKS5Config | null =
@@ -1465,18 +1331,8 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
client.connect(config);
},
);
} else if (config.sock) {
client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
return;
client.connect(config);
}
});
};
@@ -1943,7 +1799,6 @@ app.post("/host-updated", async (req, res) => {
}
try {
hostPollCache.invalidate(hostId);
const host = await fetchHostById(hostId, userId);
if (host) {
connectionPool.clearKeyConnections(getPoolKey(host));
@@ -2215,10 +2070,6 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
}
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("./vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
const connectionPromise = new Promise<{
@@ -2506,17 +2357,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
}
});
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
if (!isResolved) {
isResolved = true;
clearTimeout(timeout);
reject(error);
}
});
client.connect(config);
}
});
+1 -2
View File
@@ -4,7 +4,6 @@ import { eq, and } from "drizzle-orm";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { logger } from "../utils/logger.js";
import {
pickResolvedPassword,
pickResolvedUsername,
expandOidcUsername,
} from "./credential-username.js";
@@ -191,7 +190,7 @@ export async function resolveHostById(
if (credentials.length > 0) {
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
host.key = (cred.privateKey || cred.key) as string | null;
host.keyPassword = cred.keyPassword;
+2 -2
View File
@@ -11,7 +11,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
import { promises as fs } from "fs";
import path from "path";
import axios from "axios";
import { load as loadYaml } from "js-yaml";
import yaml from "js-yaml";
const AUTH_TIMEOUT = 60 * 1000;
@@ -131,7 +131,7 @@ async function checkOPKConfigExists(): Promise<{
let providers: ProviderRedirectInfo[] = [];
try {
const parsed = loadYaml(content) as {
const parsed = yaml.load(content) as {
providers?: Array<{
alias?: string;
issuer?: string;
+75 -150
View File
@@ -8,29 +8,18 @@ interface PooledConnection {
hostKey: string;
}
interface ConnectionWaiter {
resolve: (client: Client) => void;
reject: (error: Error) => void;
factory: () => Promise<Client>;
timer: NodeJS.Timeout;
}
const DEFAULT_MAX_CONNECTIONS_PER_HOST = 3;
const DEFAULT_MAX_WAIT_MS = 30_000;
const IDLE_MAX_AGE_MS = 10 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 2 * 60 * 1000;
class SSHConnectionPool {
private connections = new Map<string, PooledConnection[]>();
private waiters = new Map<string, ConnectionWaiter[]>();
private maxConnectionsPerHost = DEFAULT_MAX_CONNECTIONS_PER_HOST;
private maxWaitMs = DEFAULT_MAX_WAIT_MS;
private maxConnectionsPerHost = 3;
private cleanupInterval: NodeJS.Timeout;
constructor() {
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, CLEANUP_INTERVAL_MS);
this.cleanupInterval = setInterval(
() => {
this.cleanup();
},
2 * 60 * 1000,
);
}
private isConnectionHealthy(client: Client): boolean {
@@ -49,126 +38,6 @@ class SSHConnectionPool {
}
}
private removeUnhealthy(
key: string,
connections: PooledConnection[],
target: PooledConnection,
): PooledConnection[] {
sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead",
hostKey: key,
});
try {
target.client.end();
} catch {
// expected
}
const filtered = connections.filter((c) => c !== target);
this.connections.set(key, filtered);
return filtered;
}
private async createPooledClient(
key: string,
factory: () => Promise<Client>,
existing: PooledConnection[],
): Promise<Client> {
const client = await factory();
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
existing.push(pooled);
this.connections.set(key, existing);
client.on("end", () => {
this.removeConnection(key, client);
});
client.on("close", () => {
this.removeConnection(key, client);
});
return client;
}
private enqueueWaiter(
key: string,
factory: () => Promise<Client>,
): Promise<Client> {
return new Promise<Client>((resolve, reject) => {
const timer = setTimeout(() => {
const queue = this.waiters.get(key);
if (queue) {
const idx = queue.findIndex((w) => w.timer === timer);
if (idx >= 0) queue.splice(idx, 1);
if (queue.length === 0) this.waiters.delete(key);
}
const err = new Error(
`SSH connection pool wait timed out after ${this.maxWaitMs}ms (${key})`,
);
sshLogger.warn("Connection pool wait timeout", {
operation: "pool_wait_timeout",
hostKey: key,
maxWaitMs: this.maxWaitMs,
});
reject(err);
}, this.maxWaitMs);
const waiter: ConnectionWaiter = { resolve, reject, factory, timer };
const queue = this.waiters.get(key) || [];
queue.push(waiter);
this.waiters.set(key, queue);
});
}
/** Hand a free connection (or new slot) to the next waiter, if any. */
private wakeWaiter(key: string): void {
const queue = this.waiters.get(key);
if (!queue || queue.length === 0) return;
let connections = this.connections.get(key) || [];
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
} else {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
available.inUse = true;
available.lastUsed = Date.now();
waiter.resolve(available.client);
return;
}
}
if (connections.length < this.maxConnectionsPerHost) {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
this.createPooledClient(key, waiter.factory, connections)
.then(waiter.resolve)
.catch(waiter.reject);
return;
}
}
private rejectWaiters(key: string, reason: string): void {
const queue = this.waiters.get(key);
if (!queue) return;
this.waiters.delete(key);
for (const waiter of queue) {
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
}
async getConnection(
key: string,
factory: () => Promise<Client>,
@@ -178,7 +47,17 @@ class SSHConnectionPool {
const available = connections.find((conn) => !conn.inUse);
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
sshLogger.warn("Removing unhealthy connection from pool", {
operation: "pool_remove_dead",
hostKey: key,
});
try {
available.client.end();
} catch {
// expected
}
connections = connections.filter((c) => c !== available);
this.connections.set(key, connections);
} else {
available.inUse = true;
available.lastUsed = Date.now();
@@ -187,10 +66,63 @@ class SSHConnectionPool {
}
if (connections.length < this.maxConnectionsPerHost) {
return this.createPooledClient(key, factory, connections);
const client = await factory();
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
connections.push(pooled);
this.connections.set(key, connections);
client.on("end", () => {
this.removeConnection(key, client);
});
client.on("close", () => {
this.removeConnection(key, client);
});
return client;
}
return this.enqueueWaiter(key, factory);
return new Promise((resolve) => {
const checkAvailable = () => {
const conns = this.connections.get(key) || [];
const avail = conns.find((conn) => !conn.inUse);
if (avail) {
if (!this.isConnectionHealthy(avail.client)) {
try {
avail.client.end();
} catch {
// expected
}
const filtered = conns.filter((c) => c !== avail);
this.connections.set(key, filtered);
factory().then((client) => {
const pooled: PooledConnection = {
client,
lastUsed: Date.now(),
inUse: true,
hostKey: key,
};
filtered.push(pooled);
this.connections.set(key, filtered);
client.on("end", () => this.removeConnection(key, client));
client.on("close", () => this.removeConnection(key, client));
resolve(client);
});
} else {
avail.inUse = true;
avail.lastUsed = Date.now();
resolve(avail.client);
}
} else {
setTimeout(checkAvailable, 100);
}
};
checkAvailable();
});
}
releaseConnection(key: string, client: Client): void {
@@ -199,7 +131,6 @@ class SSHConnectionPool {
if (pooled) {
pooled.inUse = false;
pooled.lastUsed = Date.now();
this.wakeWaiter(key);
}
}
@@ -212,8 +143,6 @@ class SSHConnectionPool {
} else {
this.connections.set(key, filtered);
}
// A slot or idle connection may now be free for waiters.
this.wakeWaiter(key);
}
clearKeyConnections(key: string): void {
@@ -226,15 +155,15 @@ class SSHConnectionPool {
}
}
this.connections.delete(key);
this.rejectWaiters(key, `SSH connection pool cleared for ${key}`);
}
private cleanup(): void {
const now = Date.now();
const maxAge = 10 * 60 * 1000;
for (const [hostKey, connections] of this.connections.entries()) {
const activeConnections = connections.filter((conn) => {
if (!conn.inUse && now - conn.lastUsed > IDLE_MAX_AGE_MS) {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
try {
conn.client.end();
} catch {
@@ -258,14 +187,10 @@ class SSHConnectionPool {
} else {
this.connections.set(hostKey, activeConnections);
}
this.wakeWaiter(hostKey);
}
}
clearAllConnections(): void {
for (const key of [...this.waiters.keys()]) {
this.rejectWaiters(key, "SSH connection pool destroyed");
}
for (const connections of this.connections.values()) {
for (const conn of connections) {
try {
-79
View File
@@ -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",
});
});
});
-82
View File
@@ -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,
});
}
+13 -51
View File
@@ -8,19 +8,6 @@ import ssh2Pkg, {
} from "ssh2";
const { BaseAgent } = ssh2Pkg;
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
type Sleep = (ms: number) => Promise<void>;
type PortKnockingOptions = {
tcpTimeoutMs?: number;
udpTimeoutMs?: number;
createTcpSocket?: () => net.Socket;
createUdpSocket?: () => dgram.Socket;
wait?: Sleep;
};
const sleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class MemoryAgent extends BaseAgent {
private key: ParsedKey;
@@ -110,59 +97,34 @@ export async function applyAgentAuth(
export async function performPortKnocking(
host: string,
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
options: PortKnockingOptions = {},
): Promise<void> {
const createTcpSocket = options.createTcpSocket ?? (() => new net.Socket());
const createUdpSocket =
options.createUdpSocket ?? (() => dgram.createSocket("udp4"));
const wait = options.wait ?? sleep;
const tcpTimeoutMs = options.tcpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
const udpTimeoutMs = options.udpTimeoutMs ?? DEFAULT_PORT_KNOCK_TIMEOUT_MS;
for (const knock of sequence) {
const port = Number(knock.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
const protocol = (knock.protocol || "tcp").toLowerCase();
const delay = Number(knock.delay ?? 100);
const protocol = knock.protocol || "tcp";
const delay = knock.delay ?? 100;
await new Promise<void>((resolve) => {
if (protocol === "udp") {
const client = createUdpSocket();
let settled = false;
const timeout = setTimeout(() => finish(), udpTimeoutMs);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
const client = dgram.createSocket("udp4");
client.send(Buffer.alloc(0), knock.port, host, () => {
client.close();
resolve();
};
client.once("error", finish);
client.send(Buffer.alloc(0), port, host, () => {
finish();
});
} else {
const socket = createTcpSocket();
let settled = false;
const timeout = setTimeout(() => finish(), tcpTimeoutMs);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
socket.removeAllListeners("connect");
socket.removeAllListeners("error");
const socket = new net.Socket();
socket.once("connect", () => {
socket.destroy();
resolve();
};
socket.once("connect", finish);
socket.once("error", finish);
socket.connect(port, host);
});
socket.once("error", () => {
socket.destroy();
resolve();
});
socket.connect(knock.port, host);
}
});
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";
// Stub all external imports before loading the module under test
const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]);
const mockInsertValues = vi.fn().mockReturnValue({ returning: mockReturning });
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
vi.mock("../database/db/index.js", () => ({
@@ -30,25 +29,21 @@ vi.mock("../utils/logger.js", () => ({
// Mock individual fs.promises methods via a stub object
const mockMkdir = vi.fn().mockResolvedValue(undefined);
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
const mockAppendFile = vi.fn().mockResolvedValue(undefined);
const mockUnlink = vi.fn().mockResolvedValue(undefined);
vi.mock("fs", () => ({
default: {
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
unlink: vi.fn(),
},
},
promises: {
mkdir: mockMkdir,
writeFile: mockWriteFile,
appendFile: mockAppendFile,
readFile: vi.fn(),
unlink: mockUnlink,
unlink: vi.fn(),
},
}));
@@ -60,8 +55,7 @@ describe("TerminalSessionManager - session logging", () => {
// Re-apply resolved values after clearAllMocks
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);
mockReturning.mockResolvedValue([{ id: 1 }]);
mockInsertValues.mockReturnValue({ returning: mockReturning });
mockInsertValues.mockResolvedValue(undefined);
mockInsert.mockReturnValue({ values: mockInsertValues });
});
+53 -109
View File
@@ -2,7 +2,6 @@ import { type Client, type ClientChannel } from "ssh2";
import { WebSocket } from "ws";
import fs from "fs";
import path from "path";
import { eq } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js";
import { sessionRecordings } from "../database/db/schema.js";
@@ -37,12 +36,6 @@ export interface TerminalSession {
outputBuffer: string[];
outputBufferBytes: number;
recordingPath: string | null;
recordingHeader: string | null;
recordingBytes: number;
recordingId: number | null;
recordingWriteChain: Promise<void>;
recordingPersistChain: Promise<void>;
tmuxSessionName: string | null;
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
@@ -124,19 +117,6 @@ class TerminalSessionManager {
const id = crypto.randomUUID();
const now = Date.now();
let recordingPath: string | null = null;
let recordingHeader: string | null = null;
if (sessionLoggingEnabled) {
const userLogDir = path.join(SESSION_LOGS_DIR, userId);
recordingPath = path.join(userLogDir, `${id}.cast`);
recordingHeader = `${JSON.stringify({
version: 2,
width: cols,
height: rows,
timestamp: Math.floor(now / 1000),
env: { TERM: "xterm-256color", SHELL: "/bin/sh" },
})}\n`;
}
const session: TerminalSession = {
id,
userId,
@@ -155,12 +135,6 @@ class TerminalSessionManager {
detachTimeout: null,
outputBuffer: [],
outputBufferBytes: 0,
recordingPath,
recordingHeader,
recordingBytes: 0,
recordingId: null,
recordingWriteChain: Promise.resolve(),
recordingPersistChain: Promise.resolve(),
tmuxSessionName: null,
sessionLoggingEnabled,
sessionStartedAt: now,
@@ -360,9 +334,6 @@ class TerminalSessionManager {
}
this.maybePersistLog(session, true);
if (session.recordingPath && session.recordingBytes === 0) {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
if (session.sshStream) {
try {
@@ -405,51 +376,65 @@ class TerminalSessionManager {
});
}
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.recordingBytes === 0) return;
if (!force && session.recordingBytes === session.lastPersistedBytes) return;
session.lastPersistedBytes = session.recordingBytes;
session.recordingPersistChain = session.recordingPersistChain
.then(() => this.persistSessionLog(session))
.catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
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 async persistSessionLog(session: TerminalSession): Promise<void> {
if (!session.recordingPath) return;
await session.recordingWriteChain;
private maybePersistLog(session: TerminalSession, force = false): void {
if (!session.sessionLoggingEnabled) return;
if (session.outputBufferBytes === 0) return;
// Only save if new output arrived since last persist (unless forced)
if (!force && session.outputBufferBytes === session.lastPersistedBytes)
return;
const snapshot = session.outputBuffer.join("");
session.lastPersistedBytes = session.outputBufferBytes;
this.persistSessionLog(session, snapshot).catch((err) => {
sshLogger.warn("Failed to persist session log", {
operation: "session_log_persist_error",
sessionId: session.id,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async persistSessionLog(
session: TerminalSession,
logContent: string,
): Promise<void> {
const cleaned = this.stripAnsi(logContent);
if (!cleaned.trim()) return;
const userLogDir = path.join(SESSION_LOGS_DIR, session.userId);
await fs.promises.mkdir(userLogDir, { recursive: true });
const logFile = path.join(userLogDir, `${session.id}.log`);
await fs.promises.writeFile(logFile, cleaned, "utf-8");
const endedAt = Date.now();
const duration = Math.floor((endedAt - session.sessionStartedAt) / 1000);
try {
const db = getDb();
if (session.recordingId == null) {
const rows = await db
.insert(sessionRecordings)
.values({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
})
.returning({ id: sessionRecordings.id });
session.recordingId = rows[0]?.id ?? null;
} else {
await db
.update(sessionRecordings)
.set({ endedAt: new Date(endedAt).toISOString(), duration })
.where(eq(sessionRecordings.id, session.recordingId));
}
await db.insert(sessionRecordings).values({
hostId: session.hostId,
userId: session.userId,
startedAt: new Date(session.sessionStartedAt).toISOString(),
endedAt: new Date(endedAt).toISOString(),
duration,
recordingPath: logFile,
});
} catch (err) {
sshLogger.warn("Failed to insert session recording row", {
operation: "session_recording_insert_error",
@@ -464,7 +449,7 @@ class TerminalSessionManager {
userId: session.userId,
hostId: session.hostId,
duration,
bytes: session.recordingBytes,
bytes: cleaned.length,
});
}
@@ -492,47 +477,6 @@ class TerminalSessionManager {
const removed = session.outputBuffer.shift();
if (removed) session.outputBufferBytes -= removed.length;
}
this.recordSessionEvent(session, "o", data);
}
bufferInput(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "i", data);
}
bufferResize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.recordSessionEvent(session, "r", `${cols}x${rows}`);
}
private recordSessionEvent(
session: TerminalSession,
type: "i" | "o" | "r",
data: string,
): void {
if (!session.sessionLoggingEnabled || !session.recordingPath || !data)
return;
const elapsed = (Date.now() - session.sessionStartedAt) / 1000;
const line = `${JSON.stringify([elapsed, type, data])}\n`;
const firstEvent = session.recordingBytes === 0;
session.recordingBytes += Buffer.byteLength(line);
session.recordingWriteChain = session.recordingWriteChain.then(async () => {
if (firstEvent) {
await fs.promises.mkdir(path.dirname(session.recordingPath!), {
recursive: true,
});
await fs.promises.writeFile(
session.recordingPath!,
`${session.recordingHeader}${line}`,
"utf8",
);
return;
}
await fs.promises.appendFile(session.recordingPath!, line, "utf8");
});
}
flushBuffer(session: TerminalSession): string | null {
+1 -54
View File
@@ -37,7 +37,6 @@ import {
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { triggerLoginAlert } from "../utils/alert-trigger.js";
import { isRetriableDnsError, resolveHostForSshConnect } from "./ssh-dns.js";
interface ConnectToHostData {
cols: number;
@@ -539,9 +538,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "input": {
const inputData = data as string;
if (currentSessionId) {
sessionManager.bufferInput(currentSessionId, inputData);
}
const inputStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (inputStream) {
@@ -1146,7 +1142,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
enableSessionLogging?: boolean;
})
@@ -1190,20 +1185,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
}
if (
(!hostConfig.portKnockSequence ||
hostConfig.portKnockSequence.length === 0) &&
resolvedHostData.portKnockSequence &&
resolvedHostData.portKnockSequence.length > 0
) {
hostConfig.portKnockSequence = resolvedHostData.portKnockSequence;
sendLog(
"port_knock",
"info",
`Loaded ${resolvedHostData.portKnockSequence.length} port knock(s) from server-side host data`,
);
}
}
} catch (error) {
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
@@ -1286,39 +1267,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
sendLog("dns", "info", `Starting address resolution of ${ip}`);
let connectHost = ip;
try {
const resolution = await resolveHostForSshConnect(ip);
connectHost = resolution.host;
if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
sendLog(
"dns",
"success",
`Resolved ${ip} to ${resolution.resolvedAddress}`,
{ attempts: resolution.attempts },
);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
sshLogger.error("SSH hostname resolution failed", error, {
operation: "terminal_dns_resolve",
hostId: id,
ip,
port,
transient: isRetriableDnsError(error),
});
sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
ws.send(
JSON.stringify({
type: "error",
message: isRetriableDnsError(error)
? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
: "SSH error: Could not resolve hostname from the Termix server container.",
}),
);
cleanupAuthState(connectionTimeout);
return;
}
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
sshConn.on("ready", () => {
@@ -2357,7 +2305,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
const connectConfig: Record<string, unknown> = {
host: connectHost,
host: ip,
port,
username,
tryKeyboard: resolvedCredentials.authType !== "tailscale",
@@ -2916,7 +2864,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (session) {
session.cols = data.cols;
session.rows = data.rows;
sessionManager.bufferResize(session.id, data.cols, data.rows);
}
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
+3 -35
View File
@@ -1,52 +1,20 @@
import { EventEmitter } from "node:events";
import type { Client } from "ssh2";
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", () => {
it("adds common non-login shell tmux paths", () => {
const command = withTmuxPath("command -v tmux");
expect(command).toMatch(/^\/bin\/sh -c '/);
expect(command).toContain("/opt/homebrew/bin");
expect(command).toContain("/usr/local/bin");
expect(command).toContain("/opt/bin");
expect(command).toContain("/usr/pkg/bin");
expect(command).toContain(":$PATH; export PATH; command -v tmux");
expect(command).toContain(":$PATH; command -v tmux");
});
it("wraps tmux invocations with the same path", () => {
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");
});
});
+2 -3
View File
@@ -22,8 +22,7 @@ const TMUX_PATH_DIRS = [
];
export function withTmuxPath(command: string): string {
const script = `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; export PATH; ${command}`;
return `/bin/sh -c ${shellEscape(script)}`;
return `PATH=${TMUX_PATH_DIRS.join(":")}:$PATH; ${command}`;
}
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> {
try {
await execCommand(conn, tmuxCommand("-V"));
await execCommand(conn, withTmuxPath("command -v tmux"));
} catch {
return { available: false, sessions: [] };
}
+3 -21
View File
@@ -20,8 +20,7 @@ import {
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { withConnection } from "./ssh-connection-pool.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { execCommand, tmuxCommand } from "./tmux-helper.js";
import { execCommand, tmuxCommand, withTmuxPath } from "./tmux-helper.js";
import {
SEP,
parseSessions,
@@ -96,8 +95,6 @@ async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
}
} else if (host.authType === "none") {
// no credentials needed
} else if (host.authType === "vault") {
// cert auth setup happens in connectToHost (needs client instance)
} else if (host.authType === "agent") {
const result = await applyAgentAuth(
base as Record<string, unknown>,
@@ -121,12 +118,6 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
const config = await buildSshConfig(host);
const client = new Client();
if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("./vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
const proxyConfig: SOCKS5Config | null =
host.useSocks5 &&
(host.socks5Host ||
@@ -210,17 +201,8 @@ export function connectToHost(host: SSHHost): () => Promise<Client> {
client.connect(config);
},
);
} else if (config.sock) {
client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
client.connect(config);
}
});
};
@@ -246,7 +228,7 @@ async function withHostConnection<T>(
async function tmuxAvailable(conn: Client): Promise<boolean> {
try {
await execCommand(conn, tmuxCommand("-V"));
await execCommand(conn, withTmuxPath("command -v tmux"));
return true;
} catch {
return false;
+4 -27
View File
@@ -61,7 +61,6 @@ import {
handleC2SRelayTest,
type C2SOpenMessage,
} from "./tunnel-c2s-relay.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
import { handleSocks5Connect } from "./tunnel-socks5-relay.js";
const app = express();
@@ -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);
}
@@ -1715,10 +1693,6 @@ async function killRemoteTunnelByMarker(
}
}
if (!connOptions.sock) {
await resolveSshConnectConfigHost(connOptions);
}
return new Promise<Client>((resolve, reject) => {
const conn = new Client();
conn.on("ready", () => resolve(conn));
@@ -1963,7 +1937,6 @@ app.post(
}
const tunnelName = tunnelConfig.name;
tunnelConfig.requestingUserId = userId;
try {
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
@@ -1994,6 +1967,10 @@ app.post(
});
return res.status(403).json({ error: "Access denied to this host" });
}
if (accessInfo.isShared && !accessInfo.isOwner) {
tunnelConfig.requestingUserId = userId;
}
}
if (pendingTunnelOperations.has(tunnelName)) {
-39
View File
@@ -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");
});
});
+1 -65
View File
@@ -8,7 +8,7 @@ import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { db } from "../database/db/index.js";
import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
import { eq, and, inArray, sql } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
@@ -42,7 +42,6 @@ interface WrappedDataKey {
interface AuthenticatedRequest extends Request {
userId?: string;
sessionId?: string;
apiKeyId?: string;
pendingTOTP?: boolean;
dataKey?: Buffer;
}
@@ -346,9 +345,6 @@ class AuthManager {
rememberMe?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
oidcSub?: string | null;
oidcSid?: string | null;
ssoProviderId?: number | null;
} = {},
): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret();
@@ -395,9 +391,6 @@ class AuthManager {
jwtToken: token,
deviceType: options.deviceType,
deviceInfo: options.deviceInfo,
oidcSub: options.oidcSub ?? null,
oidcSid: options.oidcSid ?? null,
ssoProviderId: options.ssoProviderId ?? null,
createdAt,
expiresAt,
lastActiveAt: createdAt,
@@ -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> {
try {
const expiredSessions = await db
@@ -882,7 +819,6 @@ class AuthManager {
});
req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id;
next();
} catch (error) {
databaseLogger.error("API key authentication failed", error, {
@@ -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;
}
}
-50
View File
@@ -3,7 +3,6 @@ import {
getRequestBasePath,
getRequestBaseUrl,
getRequestBaseUrlWithForceHTTPS,
getRequestOrigin,
normalizeBasePath,
} from "./request-origin.js";
@@ -107,52 +106,3 @@ describe("getRequestBasePath", () => {
).toBe("https://example.com/termix");
});
});
describe("getRequestOrigin", () => {
it("ignores non-numeric forwarded ports", () => {
expect(
getRequestOrigin(
request({
"x-forwarded-proto": "https",
"x-forwarded-host": "termix.test.de",
"x-forwarded-port": "{server_port}",
}),
),
).toBe("https://termix.test.de");
});
it("drops invalid ports embedded in forwarded hosts", () => {
expect(
getRequestOrigin(
request({
"x-forwarded-proto": "https",
"x-forwarded-host": "termix.test.de:{server_port}",
}),
),
).toBe("https://termix.test.de");
});
it("keeps valid non-default forwarded ports", () => {
expect(
getRequestOrigin(
request({
"x-forwarded-proto": "https",
"x-forwarded-host": "termix.test.de",
"x-forwarded-port": "8443",
}),
),
).toBe("https://termix.test.de:8443");
});
it("omits default forwarded ports", () => {
expect(
getRequestOrigin(
request({
"x-forwarded-proto": "https",
"x-forwarded-host": "termix.test.de",
"x-forwarded-port": "443",
}),
),
).toBe("https://termix.test.de");
});
});
+23 -46
View File
@@ -7,44 +7,6 @@ function firstHeaderValue(value: string | string[] | undefined): string {
return raw.split(",")[0].trim();
}
function normalizePort(value: string | string[] | undefined): string {
const raw = firstHeaderValue(value);
if (!/^\d+$/.test(raw)) return "";
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) return "";
return String(port);
}
function splitHostHeader(value: string | string[] | undefined): {
host: string;
port: string;
} {
const raw = firstHeaderValue(value) || "localhost";
if (raw.startsWith("[")) {
const match = raw.match(/^(\[[^\]]+\])(?::(.+))?$/);
return {
host: match?.[1] || raw,
port: normalizePort(match?.[2]),
};
}
const parts = raw.split(":");
if (parts.length === 2) {
return {
host: parts[0] || "localhost",
port: normalizePort(parts[1]),
};
}
return {
host: raw,
port: "",
};
}
export function normalizeBasePath(value: unknown): string {
if (typeof value !== "string") return "";
let basePath = value.split(",")[0].trim();
@@ -83,23 +45,38 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
: "http";
}
let port = normalizePort(req.headers["x-forwarded-port"]);
const { host, port: hostPort } = splitHostHeader(
req.headers["x-forwarded-host"] || req.headers.host,
);
port ||= hostPort;
const portHeader = req.headers["x-forwarded-port"];
let port: string | undefined =
typeof portHeader === "string"
? portHeader.split(",")[0].trim()
: 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) {
const isDefaultPort =
(protocol === "http" && port === "80") ||
(protocol === "https" && port === "443");
return isDefaultPort
? `${protocol}://${host}`
: `${protocol}://${host}:${port}`;
? `${protocol}://${hostWithoutPort}`
: `${protocol}://${hostWithoutPort}:${port}`;
}
return `${protocol}://${host}`;
return `${protocol}://${hostWithoutPort}`;
}
export function getRequestOriginWithForceHTTPS(
-3
View File
@@ -10,7 +10,6 @@ import { Toaster } from "@/components/sonner";
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
import { getUserInfo, getCurrentToken, appReadyPromise } from "@/main-axios";
import { applyAccentColor, applyFontSize } from "@/lib/theme";
import { installElectronWheelZoomGuard } from "@/lib/electron-wheel-zoom";
import type { FontSizeId } from "@/types/ui-types";
import { useServiceWorker } from "@/hooks/use-service-worker";
import { useTranslation } from "react-i18next";
@@ -341,8 +340,6 @@ function RootApp() {
return <App />;
}
installElectronWheelZoomGuard();
prepareClientCacheVersion().finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
-20
View File
@@ -41,26 +41,6 @@ declare module "guacamole-common-js" {
constructor(url: string);
}
class SessionRecording {
constructor(source: Blob | Tunnel, refreshInterval?: number);
onload: (() => void) | null;
onerror: ((message: string) => void) | null;
onprogress: ((duration: number, parsedSize: number) => void) | null;
onplay: (() => void) | null;
onpause: (() => void) | null;
onseek:
| ((position: number, current: number, total: number) => void)
| null;
getDisplay(): Display;
getPosition(): number;
getDuration(): number;
isPlaying(): boolean;
play(): void;
pause(): void;
seek(position: number, callback?: () => void): void;
abort(): void;
}
class Mouse {
constructor(element: HTMLElement);
onmousedown: ((state: Mouse.State) => void) | null;
+1 -5
View File
@@ -109,8 +109,7 @@ export interface Host {
| "none"
| "opkssh"
| "tailscale"
| "agent"
| "vault";
| "agent";
useWarpgate?: boolean;
password?: string;
key?: string;
@@ -124,8 +123,6 @@ export interface Host {
autostartKeyPassword?: string;
credentialId?: number;
vaultProfileId?: number | null;
vaultProfile?: { id?: number | null };
overrideCredentialUsername?: boolean;
userId?: string;
enableTerminal: boolean;
@@ -955,7 +952,6 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface AuthenticatedRequest extends Request {
userId: string;
sessionId?: string;
apiKeyId?: string;
user?: {
id: string;
username: string;
+211 -305
View File
@@ -6,101 +6,30 @@ import { Separator } from "@/components/separator";
import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet";
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
import {
useState,
useRef,
useCallback,
useEffect,
createRef,
lazy,
Suspense,
} from "react";
import { useState, useRef, useCallback, useEffect, createRef } from "react";
import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { CommandPalette } from "@/shell/CommandPalette";
import { AppRail } from "@/sidebar/AppRail";
import type { RailView } from "@/sidebar/AppRail";
import { HostsPanel } from "@/sidebar/HostsPanel";
import { QuickConnectPanel } from "@/sidebar/QuickConnectPanel";
import { SerialPanel } from "@/sidebar/SerialPanel";
import { SshToolsPanel } from "@/sidebar/SshToolsPanel";
import { SnippetsPanel } from "@/sidebar/SnippetsPanel";
import { HistoryPanel } from "@/sidebar/HistoryPanel";
import { SessionLogsPanel } from "@/sidebar/SessionLogsPanel";
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import { AlertsPanel } from "@/sidebar/AlertsPanel";
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
import { TermixIdPanel } from "@/sidebar/TermixIdPanel";
import { SplitView } from "@/shell/SplitView";
import { renderTabContent } from "@/shell/tabUtils";
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager";
import { TabBar } from "@/shell/TabBar";
// Shell surfaces that are not needed for first paint.
const CommandPalette = lazy(() =>
import("@/shell/CommandPalette").then((m) => ({
default: m.CommandPalette,
})),
);
const HostsPanel = lazy(() =>
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
);
const QuickConnectPanel = lazy(() =>
import("@/sidebar/QuickConnectPanel").then((m) => ({
default: m.QuickConnectPanel,
})),
);
const SerialPanel = lazy(() =>
import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })),
);
const SplitScreenPanel = lazy(() =>
import("@/sidebar/SplitScreenPanel").then((m) => ({
default: m.SplitScreenPanel,
})),
);
const AlertManager = lazy(() =>
import("@/dashboard/panels/alerts/AlertManager").then((m) => ({
default: m.AlertManager,
})),
);
// Secondary rail panels — load on first open, not with the shell critical path.
const SshToolsPanel = lazy(() =>
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
);
const SnippetsPanel = lazy(() =>
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
);
const HistoryPanel = lazy(() =>
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
);
const SessionLogsPanel = lazy(() =>
import("@/sidebar/SessionLogsPanel").then((m) => ({
default: m.SessionLogsPanel,
})),
);
const UserProfilePanel = lazy(() =>
import("@/sidebar/UserProfilePanel").then((m) => ({
default: m.UserProfilePanel,
})),
);
const AdminSettingsPanel = lazy(() =>
import("@/sidebar/AdminSettingsPanel").then((m) => ({
default: m.AdminSettingsPanel,
})),
);
const AlertsPanel = lazy(() =>
import("@/sidebar/AlertsPanel").then((m) => ({ default: m.AlertsPanel })),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const TermixIdPanel = lazy(() =>
import("@/sidebar/TermixIdPanel").then((m) => ({ default: m.TermixIdPanel })),
);
const ConnectionsPanel = lazy(() =>
import("@/sidebar/ConnectionsPanel").then((m) => ({
default: m.ConnectionsPanel,
})),
);
function SidebarPanelFallback() {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
import type {
Tab,
TabType,
@@ -130,10 +59,10 @@ import {
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { ConnectionsPanel } from "@/sidebar/ConnectionsPanel";
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { changeAppLanguage } from "@/i18n/i18n";
function buildHostTree(
hosts: SSHHostWithStatus[],
@@ -689,7 +618,8 @@ export function AppShell({
applyAccentColor(prefs.accentColor);
}
if (prefs.language && prefs.language !== i18n.language) {
void changeAppLanguage(prefs.language);
localStorage.setItem("i18nextLng", prefs.language);
void i18n.changeLanguage(prefs.language);
}
if (
prefs.commandAutocomplete !== null &&
@@ -808,17 +738,8 @@ export function AppShell({
}, [loadHosts]);
useEffect(() => {
const onHostsChanged = () => {
void loadHosts();
};
window.addEventListener("termix:hosts-changed", onHostsChanged);
window.addEventListener("ssh-hosts:changed", onHostsChanged);
window.addEventListener("hosts:refresh", onHostsChanged);
return () => {
window.removeEventListener("termix:hosts-changed", onHostsChanged);
window.removeEventListener("ssh-hosts:changed", onHostsChanged);
window.removeEventListener("hosts:refresh", onHostsChanged);
};
window.addEventListener("termix:hosts-changed", loadHosts);
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
}, [loadHosts]);
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
@@ -1516,174 +1437,169 @@ export function AppShell({
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet
const sidebarPanelContent = (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
>
<HostsPanel
onOpenTab={(host, type) => {
connectHost(host, type);
if (isMobile) setSidebarOpen(false);
}}
onEditHost={editHostInManager}
hostTree={realHostTree ?? undefined}
loading={hostsLoading}
onEditingChange={setSidebarEditing}
active={railView === "hosts"}
/>
</div>
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
>
<CredentialsPanel
onEditingChange={setSidebarEditing}
active={railView === "credentials"}
/>
</div>
{railView === "termix-id" && (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel />
</div>
)}
{railView === "serial" && (
<SerialPanel
onConnect={(config) => {
openSerialTab(config);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "quick-connect" && (
<QuickConnectPanel
onConnect={(host, type) => {
openTab(host, type);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "ssh-tools" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SshToolsPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div>
)}
{railView === "snippets" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SnippetsPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div>
)}
{railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div>
)}
{railView === "split-screen" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SplitScreenPanel
tabs={tabs}
splitMode={splitMode}
setSplitMode={setSplitMode}
paneTabIds={paneTabIds}
setPaneTabIds={setPaneTabIds}
onAssignPane={assignPane}
/>
</div>
)}
{railView === "connections" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<ConnectionsPanel
tabs={tabs}
activeTabId={activeTabId}
allHosts={allHosts}
backgroundTabRecords={backgroundTabRecords}
onSwitchToTab={(tabId) => {
setActiveTabId(tabId);
if (isMobile) setSidebarOpen(false);
}}
onCloseTab={closeTab}
onReopenTab={(record, restoredSessionId) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
const hostlessTypes: TabType[] = ["tunnel"];
if (!host && !hostlessTypes.includes(record.tabType as TabType))
return;
setBackgroundTabRecords((prev) =>
prev.filter((r) => r.id !== record.id),
);
if (host) {
const effectiveSessionId =
restoredSessionId ?? record.backendSessionId ?? null;
openTab(host, record.tabType as TabType, {
instanceId: record.id,
restoredSessionId: effectiveSessionId,
savedLabel: record.label,
});
} else {
openSingletonTab(record.tabType as TabType);
}
if (isMobile) setSidebarOpen(false);
}}
onForgetBackground={(recordId) => {
setBackgroundTabRecords((prev) =>
prev.filter((r) => r.id !== recordId),
);
}}
onRenameTab={renameTab}
onReorderTabs={setTabs}
/>
</div>
)}
{railView === "session-logs" && (
<div className="relative flex-1 min-h-0 flex flex-col">
<SessionLogsPanel />
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
username={username}
onLogout={onLogout}
onChangeServer={onChangeServer}
userPrefs={userPrefs}
onPrefsChange={setUserPrefs}
/>
</div>
)}
{railView === "admin-settings" && isAdmin && (
<div className="flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel />
</div>
)}
{railView === "alerts" && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<AlertsPanel />
</div>
)}
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "hosts" ? "" : "hidden"}`}
>
<HostsPanel
onOpenTab={(host, type) => {
connectHost(host, type);
if (isMobile) setSidebarOpen(false);
}}
onEditHost={editHostInManager}
hostTree={realHostTree ?? undefined}
loading={hostsLoading}
onEditingChange={setSidebarEditing}
active={railView === "hosts"}
/>
</div>
</Suspense>
<div
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
>
<CredentialsPanel
onEditingChange={setSidebarEditing}
active={railView === "credentials"}
/>
</div>
{railView === "termix-id" && (
<div className="flex flex-col flex-1 min-h-0">
<TermixIdPanel />
</div>
)}
{railView === "serial" && (
<SerialPanel
onConnect={(config) => {
openSerialTab(config);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "quick-connect" && (
<QuickConnectPanel
onConnect={(host, type) => {
openTab(host, type);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "ssh-tools" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SshToolsPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div>
)}
{railView === "snippets" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SnippetsPanel
terminalTabs={terminalTabs}
activeTabId={activeTabId}
/>
</div>
)}
{railView === "history" && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<HistoryPanel terminalTabs={terminalTabs} activeTabId={activeTabId} />
</div>
)}
{railView === "split-screen" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SplitScreenPanel
tabs={tabs}
splitMode={splitMode}
setSplitMode={setSplitMode}
paneTabIds={paneTabIds}
setPaneTabIds={setPaneTabIds}
onAssignPane={assignPane}
/>
</div>
)}
{railView === "connections" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<ConnectionsPanel
tabs={tabs}
activeTabId={activeTabId}
allHosts={allHosts}
backgroundTabRecords={backgroundTabRecords}
onSwitchToTab={(tabId) => {
setActiveTabId(tabId);
if (isMobile) setSidebarOpen(false);
}}
onCloseTab={closeTab}
onReopenTab={(record, restoredSessionId) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
const hostlessTypes: TabType[] = ["tunnel"];
if (!host && !hostlessTypes.includes(record.tabType as TabType))
return;
setBackgroundTabRecords((prev) =>
prev.filter((r) => r.id !== record.id),
);
if (host) {
const effectiveSessionId =
restoredSessionId ?? record.backendSessionId ?? null;
openTab(host, record.tabType as TabType, {
instanceId: record.id,
restoredSessionId: effectiveSessionId,
savedLabel: record.label,
});
} else {
openSingletonTab(record.tabType as TabType);
}
if (isMobile) setSidebarOpen(false);
}}
onForgetBackground={(recordId) => {
setBackgroundTabRecords((prev) =>
prev.filter((r) => r.id !== recordId),
);
}}
onRenameTab={renameTab}
onReorderTabs={setTabs}
/>
</div>
)}
{railView === "session-logs" && (
<div className="relative flex-1 min-h-0 flex flex-col">
<SessionLogsPanel />
</div>
)}
{railView === "user-profile" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<UserProfilePanel
username={username}
onLogout={onLogout}
onChangeServer={onChangeServer}
userPrefs={userPrefs}
onPrefsChange={setUserPrefs}
/>
</div>
)}
{railView === "admin-settings" && isAdmin && (
<div className="flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel />
</div>
)}
{railView === "alerts" && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<AlertsPanel />
</div>
)}
</div>
);
// Sidebar header — shared
@@ -1797,10 +1713,6 @@ export function AppShell({
onAddToSplit={addTabToSplit}
onRemoveFromSplit={removeTabFromSplit}
onRenameTab={renameTab}
onOpenFileManager={(tabId) => {
const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) openTab(targetTab.host, "files");
}}
isAppFullscreen={isAppFullscreen}
onToggleAppFullscreen={toggleAppFullscreen}
/>
@@ -1896,41 +1808,35 @@ export function AppShell({
</div>
</div>
{commandPaletteOpen && (
<Suspense fallback={null}>
<CommandPalette
isOpen={commandPaletteOpen}
setIsOpen={setCommandPaletteOpen}
hosts={allHosts}
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
].includes(type)
) {
openSingletonTab(type, pendingEvent);
} else if (type === "tmux_monitor") {
// --- tmux-monitor --- singleton tab, optionally preselecting a host
openSingletonTab(
type,
undefined,
label ? allHosts.find((h) => h.name === label) : undefined,
);
} else if (label) {
const host = allHosts.find((h) => h.name === label);
if (host) openTab(host, type);
}
}}
/>
</Suspense>
)}
<CommandPalette
isOpen={commandPaletteOpen}
setIsOpen={setCommandPaletteOpen}
hosts={allHosts}
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
].includes(type)
) {
openSingletonTab(type, pendingEvent);
} else if (type === "tmux_monitor") {
// --- tmux-monitor --- singleton tab, optionally preselecting a host
openSingletonTab(
type,
undefined,
label ? allHosts.find((h) => h.name === label) : undefined,
);
} else if (label) {
const host = allHosts.find((h) => h.name === label);
if (host) openTab(host, type);
}
}}
/>
<TransferMonitor />
<Suspense fallback={null}>
<AlertManager userId={userId} loggedIn={!!username} />
</Suspense>
<AlertManager userId={userId} loggedIn={!!username} />
</ServerStatusProvider>
);
}
-45
View File
@@ -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);
});
});
+1 -46
View File
@@ -40,28 +40,6 @@ export interface AlertFiring {
ruleName?: string;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumberValue(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function severityValue(value: unknown): AlertFiring["severity"] {
return value === "info" || value === "critical" || value === "warning"
? value
: "warning";
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
@@ -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[]> {
const res = await rbacApi.get("/alert-rules");
return (res.data as Record<string, unknown>[]).map(mapRule);
@@ -160,13 +121,7 @@ export async function getAlertFirings(opts?: {
acknowledged?: boolean;
}): Promise<AlertFiring[]> {
const res = await rbacApi.get("/alert-firings", { params: opts });
const data = res.data as { firings?: unknown } | unknown[];
const rows = Array.isArray(data)
? data
: Array.isArray(data.firings)
? data.firings
: [];
return rows.map((row) => mapAlertFiring(row as Record<string, unknown>));
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
}
export async function acknowledgeAlertFiring(id: number): Promise<void> {
-47
View File
@@ -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);
});
});
+1 -42
View File
@@ -34,40 +34,6 @@ export interface AuditLogResponse {
totalPages: number;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableStringValue(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
export function mapAuditLog(row: Record<string, unknown>): AuditLog {
return {
id: numberValue(row.id),
userId: stringValue(row.userId ?? row.user_id),
username: stringValue(row.username),
action: stringValue(row.action, "unknown"),
resourceType: stringValue(row.resourceType ?? row.resource_type, "unknown"),
resourceId: nullableStringValue(row.resourceId ?? row.resource_id),
resourceName: nullableStringValue(row.resourceName ?? row.resource_name),
details: nullableStringValue(row.details),
ipAddress: nullableStringValue(row.ipAddress ?? row.ip_address),
userAgent: nullableStringValue(row.userAgent ?? row.user_agent),
success: boolValue(row.success),
errorMessage: nullableStringValue(row.errorMessage ?? row.error_message),
timestamp: stringValue(row.timestamp, new Date(0).toISOString()),
};
}
export async function getAuditLogs(
filters: AuditLogFilters = {},
): Promise<AuditLogResponse> {
@@ -84,14 +50,7 @@ export async function getAuditLogs(
if (filters.endDate) params.set("endDate", filters.endDate);
const response = await authApi.get(`/audit-logs?${params.toString()}`);
const data = response.data as Partial<AuditLogResponse>;
const rows = Array.isArray(data.logs) ? data.logs : [];
return {
logs: rows.map((row) => mapAuditLog(row as Record<string, unknown>)),
total: numberValue(data.total),
page: numberValue(data.page, filters.page ?? 1),
totalPages: numberValue(data.totalPages, 1),
};
return response.data;
} catch (error) {
handleApiError(error, "fetch audit logs");
}
+23 -27
View File
@@ -1,7 +1,6 @@
import axios, { type AxiosRequestConfig } from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
@@ -74,37 +73,34 @@ function isTransientStatusError(error: unknown): boolean {
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
let lastError: unknown = null;
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {};
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {};
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
}
}
handleApiError(lastError, "fetch server statuses");
return {};
});
handleApiError(lastError, "fetch server statuses");
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
+2 -7
View File
@@ -1,5 +1,4 @@
import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
// OPEN TABS API
// ============================================================================
@@ -43,8 +42,6 @@ export interface ActiveSessionInfo {
createdAt: number;
}
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
export async function getOpenTabs(): Promise<OpenTabRecord[]> {
const response = await authApi.get("/open-tabs");
return response.data;
@@ -72,10 +69,8 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
}
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
return activeSessionsCache.get(async () => {
const response = await authApi.get("/open-tabs/active-sessions");
return Array.isArray(response.data) ? response.data : [];
});
const response = await authApi.get("/open-tabs/active-sessions");
return response.data;
}
// ============================================================================
-33
View File
@@ -11,9 +11,6 @@ export type SessionLogRecord = {
hostName: string | null;
hostIp: string | null;
sizeBytes: number | null;
protocol: "ssh" | "rdp" | "vnc" | "telnet";
format: "text" | "asciicast" | "guacamole";
username: string | null;
};
export async function getSessionLogs(): Promise<SessionLogRecord[]> {
@@ -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> {
try {
const response = await authApi.get(`/session_logs/${id}/content`, {
+1 -1
View File
@@ -410,7 +410,7 @@ export async function uploadSSHFile(
form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName);
const response = await fileManagerApi.postForm(
const response = await fileManagerApi.post(
"/ssh/uploadFileChunk",
form,
{ timeout: 0 },
+5 -35
View File
@@ -8,38 +8,16 @@ import {
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxDiscoverResult } from "@/types/proxmox";
import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
} from "@/lib/hosts-request-cache";
// SSH HOST MANAGEMENT
// ============================================================================
export type GetSSHHostsOptions = {
/** When false, skip the status service call (host config only). Default true. */
includeStatus?: boolean;
};
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host");
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
}
export async function getSSHHosts(
options: GetSSHHostsOptions = {},
): Promise<SSHHostWithStatus[]> {
const includeStatus = options.includeStatus !== false;
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
try {
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
if (!includeStatus) {
return hosts.map((host) => ({
...host,
status: "unknown",
}));
}
const hostsResponse = await sshHostApi.get("/db/host");
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
let statuses: Record<number, ServerStatus> = {};
try {
@@ -67,11 +45,9 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
const response = await sshHostApi.post("/db/host", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
@@ -91,11 +67,9 @@ export async function updateSSHHost(
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
invalidateHostsAndStatusCaches();
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
@@ -129,7 +103,6 @@ export async function bulkImportSSHHosts(
overwrite,
...(credentials ? { credentials } : {}),
});
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "bulk import SSH hosts");
@@ -152,7 +125,6 @@ export async function importSSHConfigHosts(
content,
overwrite,
});
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "import SSH config hosts");
@@ -183,7 +155,6 @@ export async function bulkUpdateSSHHosts(
hostIds,
updates,
});
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "bulk update SSH hosts");
@@ -195,7 +166,6 @@ export async function deleteSSHHost(
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete(`/db/host/${hostId}`);
invalidateHostsAndStatusCaches();
return response.data;
} catch (error) {
handleApiError(error, "delete SSH host");
+6 -6
View File
@@ -41,7 +41,7 @@ import type { SSOProviderPublic } from "@/types/index";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import i18n from "@/i18n/i18n";
import {
removeSilentSigninFromSearch,
shouldTriggerSilentSignin,
@@ -238,14 +238,14 @@ export function Auth({ onLogin }: AuthProps) {
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [resetTempToken, setResetTempToken] = useState("");
const [language, setLanguage] = useState(() =>
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
const [language, setLanguage] = useState(
() => localStorage.getItem("i18nextLng") ?? "en",
);
function handleLanguageChange(code: string) {
void changeAppLanguage(code)
.then((language) => setLanguage(language))
.catch(() => {});
setLanguage(code);
localStorage.setItem("i18nextLng", code);
i18n.changeLanguage(code);
}
const [registrationAllowed, setRegistrationAllowed] = useState(true);
+6 -34
View File
@@ -531,10 +531,6 @@ function HostStatusCard({
);
}
function isStatusCheckEnabled(host: Host): boolean {
return host.statsConfig?.statusCheckEnabled !== false;
}
function RecentActivityCard({
activity,
hosts,
@@ -801,7 +797,6 @@ function CardItem({
onAddServiceLink,
onDeleteServiceLink,
statusLoading,
isVisible = true,
}: {
slot: CardSlot;
editMode: boolean;
@@ -831,7 +826,6 @@ function CardItem({
onAddServiceLink: (label: string, url: string) => Promise<void>;
onDeleteServiceLink: (id: number) => Promise<void>;
statusLoading?: boolean;
isVisible?: boolean;
}) {
const cardRef = useRef<HTMLDivElement | null>(null);
@@ -928,7 +922,6 @@ function CardItem({
{slot.id === "network_graph" && (
<NetworkGraphCard
embedded={true}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
/>
)}
@@ -1058,7 +1051,6 @@ type PanelColumnProps = {
onAddServiceLink: (label: string, url: string) => Promise<void>;
onDeleteServiceLink: (id: number) => Promise<void>;
statusLoading: boolean;
isVisible?: boolean;
};
function PanelColumn({
@@ -1090,7 +1082,6 @@ function PanelColumn({
onAddServiceLink,
onDeleteServiceLink,
statusLoading,
isVisible = true,
}: PanelColumnProps) {
const { t } = useTranslation();
const sorted = [...slots].sort((a, b) => a.order - b.order);
@@ -1147,7 +1138,6 @@ function PanelColumn({
onAddServiceLink={onAddServiceLink}
onDeleteServiceLink={onDeleteServiceLink}
statusLoading={statusLoading}
isVisible={isVisible}
/>
</div>
))}
@@ -1198,12 +1188,9 @@ function ColumnDivider({
export function DashboardTab({
onOpenSingletonTab,
onOpenTab,
isVisible = true,
}: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
onOpenTab: (host: Host, type: TabType) => void;
/** When false, pause dashboard metrics refresh while the tab stays mounted. */
isVisible?: boolean;
}) {
const { t, i18n } = useTranslation();
const { initialLoadComplete } = useServerStatus();
@@ -1296,7 +1283,6 @@ export function DashboardTab({
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
>(new Map());
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
const fetchMetrics = useCallback(async (hostList: Host[]) => {
let statuses: Record<number, { status?: string }> = {};
@@ -1356,11 +1342,8 @@ export function DashboardTab({
const load = async () => {
const raw = await getSSHHosts().catch(() => []);
const mapped = raw.map(sshHostToHost);
const statusHosts = mapped.filter(isStatusCheckEnabled);
if (mounted) setHosts(mapped);
if (isVisible) {
fetchMetrics(statusHosts).catch(() => {});
}
fetchMetrics(mapped).catch(() => {});
};
load();
@@ -1410,37 +1393,28 @@ export function DashboardTab({
})
.catch(() => {});
if (!isVisible) {
return () => {
mounted = false;
};
}
const metricsInterval = setInterval(async () => {
if (document.visibilityState === "hidden") return;
const raw = await getSSHHosts().catch(() => []);
const mapped = raw.map(sshHostToHost);
const statusHosts = mapped.filter(isStatusCheckEnabled);
if (mounted) setHosts(mapped);
fetchMetrics(statusHosts).catch(() => {});
fetchMetrics(mapped).catch(() => {});
}, 30000);
return () => {
mounted = false;
clearInterval(metricsInterval);
};
}, [fetchMetrics, isVisible]);
}, [fetchMetrics]);
useEffect(() => {
if (!isVisible || viewerSessionsRef.current.size === 0) return;
if (viewerSessionsRef.current.size === 0) return;
const heartbeat = setInterval(async () => {
if (document.visibilityState === "hidden") return;
for (const [, sessionId] of viewerSessionsRef.current) {
sendMetricsHeartbeat(sessionId).catch(() => {});
}
}, 30000);
return () => clearInterval(heartbeat);
}, [hostMetrics, isVisible]);
}, [hostMetrics]);
const handleClearActivity = async () => {
try {
@@ -1613,7 +1587,6 @@ export function DashboardTab({
onAddServiceLink: handleAddServiceLink,
onDeleteServiceLink: handleDeleteServiceLink,
statusLoading,
isVisible,
};
const isMobile = useIsMobile();
@@ -1734,7 +1707,7 @@ export function DashboardTab({
)}
{slot.id === "host_status" && (
<HostStatusCard
hosts={statusCheckHosts}
hosts={hosts}
hostMetrics={hostMetrics}
onOpenTab={onOpenTab}
isAdmin={isAdmin}
@@ -1753,7 +1726,6 @@ export function DashboardTab({
{slot.id === "network_graph" && (
<NetworkGraphCard
embedded={true}
isVisible={isVisible}
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
/>
)}
+2 -16
View File
@@ -92,8 +92,6 @@ interface NetworkGraphCardProps {
rightSidebarWidth?: number;
embedded?: boolean;
onOpenInNewTab?: () => void;
/** When false, pause status refresh while the surface stays mounted. */
isVisible?: boolean;
}
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
@@ -197,7 +195,6 @@ function buildNodeSvg(
export function NetworkGraphCard({
embedded = true,
onOpenInNewTab,
isVisible = true,
}: NetworkGraphCardProps): React.ReactElement {
const { t } = useTranslation();
const { addTab } = useTabsSafe();
@@ -272,19 +269,8 @@ export function NetworkGraphCard({
}, [hostMap]);
useEffect(() => {
if (!isVisible) {
if (statusIntervalRef.current) {
clearInterval(statusIntervalRef.current);
statusIntervalRef.current = null;
}
return;
}
loadData();
statusIntervalRef.current = setInterval(() => {
if (document.visibilityState === "hidden") return;
updateHostStatuses();
}, 30000);
statusIntervalRef.current = setInterval(updateHostStatuses, 30000);
const onClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
@@ -307,7 +293,7 @@ export function NetworkGraphCard({
document.removeEventListener("mousedown", onClickOutside, true);
themeObserver.disconnect();
};
}, [isVisible]);
}, []);
const loadData = async () => {
setLoading(true);
+1 -4
View File
@@ -335,10 +335,7 @@ function DockerManagerInner({
};
pollContainers();
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void pollContainers();
}, 5000);
const interval = setInterval(pollContainers, 5000);
return () => {
cancelled = true;
@@ -50,10 +50,7 @@ export function ContainerStats({
React.useEffect(() => {
fetchStats();
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchStats();
}, 2000);
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
@@ -90,10 +90,7 @@ export function LogViewer({
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchLogs();
}, 3000);
const interval = setInterval(fetchLogs, 3000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
+2 -5
View File
@@ -87,7 +87,6 @@ function FileManagerContent({
initialPath,
onClose,
onOpenTerminalTab,
isVisible = true,
}: FileManagerProps) {
const { openWindow } = useWindowManager();
const { t } = useTranslation();
@@ -269,7 +268,7 @@ function FileManagerContent({
}, [currentHost]);
useEffect(() => {
if (sshSessionId && isVisible) {
if (sshSessionId) {
startKeepalive();
} else {
stopKeepalive();
@@ -278,7 +277,7 @@ function FileManagerContent({
return () => {
stopKeepalive();
};
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
}, [sshSessionId, startKeepalive, stopKeepalive]);
const initialFileOpenedRef = useRef(false);
useEffect(() => {
@@ -3106,7 +3105,6 @@ function FileManagerInner({
initialPath,
onClose,
onOpenTerminalTab,
isVisible = true,
}: FileManagerProps) {
return (
<WindowManager>
@@ -3116,7 +3114,6 @@ function FileManagerInner({
initialPath={initialPath}
onClose={onClose}
onOpenTerminalTab={onOpenTerminalTab}
isVisible={isVisible}
/>
</WindowManager>
);
+170 -355
View File
@@ -1,14 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
useRef,
useCallback,
useEffect,
useMemo,
useLayoutEffect,
} from "react";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts";
import {
Folder,
@@ -193,12 +185,6 @@ export function FileManagerGrid({
const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState("");
const [gridCols, setGridCols] = useState(4);
const LIST_ROW_H = 41;
const GRID_ROW_H = 112;
const LIST_HEADER_H = 33;
const CONTENT_PAD = 16;
const [dragState, setDragState] = useState<DragState>({
type: "none",
@@ -206,52 +192,6 @@ export function FileManagerGrid({
counter: 0,
});
// Responsive column count for grid virtualization (matches Tailwind breakpoints roughly).
useEffect(() => {
if (viewMode !== "grid") return;
const el = gridRef.current;
if (!el) return;
const updateCols = () => {
const w = el.clientWidth - CONTENT_PAD * 2;
// gap-4 (16px) + ~min cell 96px
const n = Math.max(2, Math.min(8, Math.floor((w + 16) / 112)));
setGridCols(n);
};
updateCols();
const ro = new ResizeObserver(updateCols);
ro.observe(el);
return () => ro.disconnect();
}, [viewMode]);
const gridRowCount = useMemo(
() => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0),
[viewMode, files.length, gridCols],
);
const listVirtualizer = useVirtualizer({
count: viewMode === "list" ? files.length : 0,
getScrollElement: () => gridRef.current,
estimateSize: () => LIST_ROW_H,
overscan: 16,
getItemKey: (index) => files[index]?.path ?? index,
enabled: viewMode === "list" && files.length > 0,
});
const gridVirtualizer = useVirtualizer({
count: gridRowCount,
getScrollElement: () => gridRef.current,
estimateSize: () => GRID_ROW_H,
overscan: 6,
getItemKey: (index) => `row-${index}`,
enabled: viewMode === "grid" && files.length > 0,
});
useLayoutEffect(() => {
if (viewMode === "list") listVirtualizer.measure();
else gridVirtualizer.measure();
}, [viewMode, files.length, editingFile?.path, createIntent, gridCols]);
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === "internal" && dragState.files.length > 0) {
@@ -502,77 +442,6 @@ export function FileManagerGrid({
setSelectionRect({ x, y, width, height });
if (gridRef.current) {
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
// Virtual list: only visible DOM nodes exist. For list mode compute
// selection from scroll position + fixed row height so drag-select
// still covers off-screen rows.
if (viewMode === "list" && files.length > 0) {
const scrollTop = gridRef.current.scrollTop;
const createExtra = createIntent ? LIST_ROW_H : 0;
const contentTop =
selectionBox.top +
scrollTop -
CONTENT_PAD -
LIST_HEADER_H -
createExtra;
const contentBottom =
selectionBox.bottom +
scrollTop -
CONTENT_PAD -
LIST_HEADER_H -
createExtra;
const startIdx = Math.max(0, Math.floor(contentTop / LIST_ROW_H));
const endIdx = Math.min(
files.length - 1,
Math.ceil(contentBottom / LIST_ROW_H),
);
if (endIdx >= startIdx) {
onSelectionChange(files.slice(startIdx, endIdx + 1));
} else {
onSelectionChange([]);
}
return;
}
if (viewMode === "grid" && files.length > 0) {
const scrollTop = gridRef.current.scrollTop;
const createExtra = createIntent ? GRID_ROW_H : 0;
const contentTop =
selectionBox.top + scrollTop - CONTENT_PAD - createExtra;
const contentBottom =
selectionBox.bottom + scrollTop - CONTENT_PAD - createExtra;
const startRow = Math.max(0, Math.floor(contentTop / GRID_ROW_H));
const endRow = Math.min(
gridRowCount - 1,
Math.ceil(contentBottom / GRID_ROW_H),
);
// Column range from X within padded content.
const contentLeft = selectionBox.left - CONTENT_PAD;
const contentRight = selectionBox.right - CONTENT_PAD;
const cellW =
(gridRef.current.clientWidth - CONTENT_PAD * 2 + 16) / gridCols;
const startCol = Math.max(0, Math.floor(contentLeft / cellW));
const endCol = Math.min(
gridCols - 1,
Math.ceil(contentRight / cellW),
);
const selected: FileItem[] = [];
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
const idx = r * gridCols + c;
if (idx >= 0 && idx < files.length) selected.push(files[idx]);
}
}
onSelectionChange(selected);
return;
}
const fileElements =
gridRef.current.querySelectorAll("[data-file-path]");
const selectedPaths: string[] = [];
@@ -588,6 +457,13 @@ export function FileManagerGrid({
bottom: elementRect.bottom - containerRect.top,
};
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
const intersects = !(
relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right ||
@@ -610,16 +486,7 @@ export function FileManagerGrid({
}
}
},
[
isSelecting,
selectionStart,
files,
onSelectionChange,
viewMode,
createIntent,
gridCols,
gridRowCount,
],
[isSelecting, selectionStart, files, onSelectionChange],
);
const handleMouseUp = useCallback(
@@ -952,126 +819,89 @@ export function FileManagerGrid({
</span>
</div>
) : 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 && (
<div
className="grid gap-4"
style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}}
>
<CreateIntentGridItem
intent={createIntent}
onConfirm={onConfirmCreate}
onCancel={onCancelCreate}
/>
</div>
<CreateIntentGridItem
intent={createIntent}
onConfirm={onConfirmCreate}
onCancel={onCancelCreate}
/>
)}
<div
className="relative w-full"
style={{ height: gridVirtualizer.getTotalSize() }}
>
{gridVirtualizer.getVirtualItems().map((vRow) => {
const start = vRow.index * gridCols;
const rowFiles = files.slice(start, start + gridCols);
return (
<div
key={vRow.key}
data-index={vRow.index}
ref={gridVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vRow.start}px)`,
}}
>
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
}}
>
{rowFiles.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={file.path}
data-file-path={file.path}
draggable={true}
className={cn(
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
isSelected &&
"bg-accent-brand/10 border-accent-brand/40",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some(
(f) => f.path === file.path,
) && "opacity-50",
)}
title={file.name}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="relative mb-2 pointer-events-none">
{getFileIcon(file, viewMode)}
</div>
<div className="w-full flex flex-col items-center pointer-events-none">
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<p
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1"
title={file.name}
>
{file.name}
</p>
)}
{file.type === "file" &&
file.size !== undefined &&
file.size !== null && (
<p className="text-[10px] font-medium text-muted-foreground/60 mt-0.5">
{formatFileSize(file.size)}
</p>
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-[10px] text-accent-brand mt-0.5 truncate w-full text-center"
title={file.linkTarget}
>
{file.linkTarget}
</p>
)}
</div>
</div>
);
})}
</div>
{files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={file.path}
data-file-path={file.path}
draggable={true}
className={cn(
"group flex flex-col items-center p-3 rounded-none border-2 border-transparent transition-all cursor-pointer hover:bg-muted/50 select-none",
isSelected && "bg-accent-brand/10 border-accent-brand/40",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
)}
title={file.name}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="relative mb-2 pointer-events-none">
{getFileIcon(file, viewMode)}
</div>
);
})}
</div>
<div className="w-full flex flex-col items-center pointer-events-none">
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<p
className="text-[11px] font-bold tracking-tight text-center truncate w-full px-1"
title={file.name}
>
{file.name}
</p>
)}
{file.type === "file" &&
file.size !== undefined &&
file.size !== null && (
<p className="text-[10px] font-medium text-muted-foreground/60 mt-0.5">
{formatFileSize(file.size)}
</p>
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-[10px] text-accent-brand mt-0.5 truncate w-full text-center"
title={file.linkTarget}
>
{file.linkTarget}
</p>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex flex-col">
@@ -1122,106 +952,91 @@ export function FileManagerGrid({
onCancel={onCancelCreate}
/>
)}
<div
className="relative w-full"
style={{ height: listVirtualizer.getTotalSize() }}
>
{listVirtualizer.getVirtualItems().map((vItem) => {
const file = files[vItem.index];
if (!file) return null;
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={listVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vItem.start}px)`,
}}
>
<div
data-file-path={file.path}
draggable={true}
className={cn(
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 rounded-none select-none transition-colors",
isSelected && "bg-accent-brand/10",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
)}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="flex items-center gap-3 overflow-hidden pointer-events-none">
<div className="shrink-0">
{getFileIcon(file, viewMode)}
</div>
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<span
className="font-bold truncate tracking-tight"
title={file.name}
>
{file.name}
{file.type === "link" && file.linkTarget && (
<span className="text-accent-brand ml-1 normal-case font-normal">
{file.linkTarget}
</span>
)}
{files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={file.path}
data-file-path={file.path}
draggable={true}
className={cn(
"grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center text-xs cursor-pointer border-b border-border hover:bg-muted/50 last:border-0 rounded-none select-none transition-colors",
isSelected && "bg-accent-brand/10",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
)}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="flex items-center gap-3 overflow-hidden pointer-events-none">
<div className="shrink-0">
{getFileIcon(file, viewMode)}
</div>
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<span
className="font-bold truncate tracking-tight"
title={file.name}
>
{file.name}
{file.type === "link" && file.linkTarget && (
<span className="text-accent-brand ml-1 normal-case font-normal">
{file.linkTarget}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground pointer-events-none">
{file.modified || "—"}
</span>
<span className="text-[10px] text-muted-foreground truncate hidden md:block pointer-events-none">
{file.owner
? `${file.owner}${file.group ? `:${file.group}` : ""}`
: "—"}
</span>
<span className="text-[10px] text-right text-muted-foreground tabular-nums pointer-events-none">
{file.type === "file" &&
file.size !== undefined &&
file.size !== null
? formatFileSize(file.size)
: "—"}
</span>
<span className="text-[10px] text-right font-mono text-muted-foreground/60 pointer-events-none">
{file.permissions || "—"}
</span>
</div>
)}
</div>
);
})}
</div>
<span className="text-[10px] text-muted-foreground pointer-events-none">
{file.modified || "—"}
</span>
<span className="text-[10px] text-muted-foreground truncate hidden md:block pointer-events-none">
{file.owner
? `${file.owner}${file.group ? `:${file.group}` : ""}`
: "—"}
</span>
<span className="text-[10px] text-right text-muted-foreground tabular-nums pointer-events-none">
{file.type === "file" &&
file.size !== undefined &&
file.size !== null
? formatFileSize(file.size)
: "—"}
</span>
<span className="text-[10px] text-right font-mono text-muted-foreground/60 pointer-events-none">
{file.permissions || "—"}
</span>
</div>
);
})}
</div>
)}
@@ -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();
start();
};
void reconcileTransfers();
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [t, formatTransferMetrics]);
return null;
@@ -407,7 +407,7 @@ export function FileViewer({
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-card border-b border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
{fileTypeInfo.icon}
@@ -434,7 +434,7 @@ export function FileViewer({
</div>
</div>
<div className="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 && (
<Button
variant="ghost"
@@ -7,8 +7,6 @@ export interface FileManagerProps {
initialPath?: string;
onClose?: () => void;
onOpenTerminalTab?: (path?: string) => void;
/** When false, pause keepalive while the tab stays mounted in the background. */
isVisible?: boolean;
}
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
+1 -32
View File
@@ -12,11 +12,6 @@ import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
isFirefoxBrowser,
isPasteShortcut,
pasteTextToRemote,
} from "./guacamole-clipboard.ts";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -343,32 +338,6 @@ export const GuacamoleDisplay = forwardRef<
displayElement.setAttribute("tabindex", "0");
displayElement.style.outline = "none";
const useNativePasteFallback = isFirefoxBrowser();
if (useNativePasteFallback) {
displayElement.addEventListener(
"keydown",
(event) => {
if (isPasteShortcut(event)) {
event.stopImmediatePropagation();
}
},
true,
);
displayElement.addEventListener(
"paste",
(event) => {
if (clientRef.current !== client) return;
const text = event.clipboardData?.getData("text/plain");
if (!text) return;
event.preventDefault();
event.stopImmediatePropagation();
pasteTextToRemote(client, text);
},
true,
);
}
display.onresize = () => {
if (!isMountedRef.current) return;
rescaleDisplay(true);
@@ -607,7 +576,7 @@ export const GuacamoleDisplay = forwardRef<
const syncClipboard = useCallback(() => {
const client = clientRef.current;
if (!client || isFirefoxBrowser() || !navigator.clipboard?.readText) return;
if (!client || !navigator.clipboard?.readText) return;
navigator.clipboard
.readText()
.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",
]);
});
});
@@ -1,43 +0,0 @@
import Guacamole from "guacamole-common-js";
const CONTROL_LEFT_KEYSYM = 0xffe3;
const V_KEYSYM = 0x76;
interface ClipboardOutputStream {
sendBlob(data: string): void;
sendEnd(): void;
}
export interface GuacamoleClipboardClient {
createClipboardStream(mimetype: string): ClipboardOutputStream;
sendKeyEvent(pressed: number, keysym: number): void;
}
export function isFirefoxBrowser(userAgent = navigator.userAgent): boolean {
return /(?:Firefox|FxiOS)\//.test(userAgent);
}
export function isPasteShortcut(
event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey">,
): boolean {
return (
!event.altKey &&
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "v"
);
}
export function pasteTextToRemote(
client: GuacamoleClipboardClient,
text: string,
): void {
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
client.sendKeyEvent(1, CONTROL_LEFT_KEYSYM);
client.sendKeyEvent(1, V_KEYSYM);
client.sendKeyEvent(0, V_KEYSYM);
client.sendKeyEvent(0, CONTROL_LEFT_KEYSYM);
}
@@ -1,50 +0,0 @@
/**
* setInterval that pauses while the document is hidden and fires once on resume.
* Returns a cleanup function for useEffect.
*/
export function runVisibleInterval(
tick: () => void,
intervalMs: number,
): () => void {
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null || intervalMs <= 0) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
tick();
}, intervalMs);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
tick();
start();
};
if (
typeof document === "undefined" ||
document.visibilityState !== "hidden"
) {
start();
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
stop();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
};
}
@@ -61,35 +61,8 @@ function AlertFeedWidget({
useEffect(() => {
fetchData();
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
void fetchData();
}, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void fetchData();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
const iv = setInterval(fetchData, 30_000);
return () => clearInterval(iv);
}, [maxItems, showAcknowledged]);
const handleAck = async (id: number) => {
@@ -8,7 +8,6 @@ import type {
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function getMonthData(year: number, month: number, startOnMonday: boolean) {
const firstDay = new Date(year, month, 1).getDay();
@@ -28,7 +27,8 @@ function CalendarWidget({
const [viewMonth, setViewMonth] = useState(now.getMonth());
useEffect(() => {
return runVisibleInterval(() => setNow(new Date()), 60_000);
const iv = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(iv);
}, []);
const { daysInMonth, offset } = getMonthData(
@@ -1,20 +1,18 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Clock } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { ClockConfig, WidgetComponentProps } from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
const { timezone, showSeconds, format } = config;
const [now, setNow] = useState(new Date());
// Minute-level is enough without seconds; pause when the tab is backgrounded.
usePageVisibleInterval(
() => setNow(new Date()),
showSeconds ? 1_000 : 30_000,
);
useEffect(() => {
const iv = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(iv);
}, []);
const opts: Intl.DateTimeFormatOptions = {
hour: "2-digit",
@@ -69,32 +69,8 @@ function CountdownWidget({
}
};
update();
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(update, 1000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
update();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
const iv = setInterval(update, 1000);
return () => clearInterval(iv);
}, [targetDate]);
const titleBar = (
@@ -9,7 +9,6 @@ import type {
import { GRID_SIZE } from "@/types/homepage-types";
import { homepageApi } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function resolvePath(obj: unknown, path: string): unknown {
return path.split(".").reduce((acc: unknown, key) => {
@@ -71,9 +70,8 @@ function CustomApiWidget({
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, interval * 1000);
const iv = setInterval(fetchData, interval * 1000);
return () => clearInterval(iv);
}, [url, interval]);
const accent =
@@ -10,7 +10,6 @@ import { GRID_SIZE } from "@/types/homepage-types";
import { getRecentActivity } from "@/api/dashboard-api";
import type { RecentActivityItem } from "@/api/dashboard-api";
import { WidgetTitle } from "./WidgetTitle";
import { runVisibleInterval } from "../use-visible-interval";
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime();
@@ -44,9 +43,8 @@ function DockerActivityWidget({
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 30_000);
const iv = setInterval(fetchData, 30_000);
return () => clearInterval(iv);
}, [maxItems]);
if (loading) {
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useEffect, useState } from "react";
import { LayoutGrid } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
@@ -8,9 +8,9 @@ import type {
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getAllServerStatuses } from "@/api/host-metrics-status-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { WidgetTitle } from "./WidgetTitle";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
function getAccentColor(): string {
return (
@@ -44,26 +44,38 @@ function HostGridWidget({
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [loading, setLoading] = useState(true);
// getSSHHosts already attaches cached server status — no second /status call.
const fetchData = useCallback(async () => {
const fetchData = async () => {
try {
const allHosts = await getSSHHosts();
const [allHosts, statuses] = await Promise.all([
getSSHHosts(),
getAllServerStatuses().catch(
() => ({}) as Record<number, { status: string }>,
),
]);
const filtered =
hostIds.length > 0
? allHosts.filter((h) => hostIds.includes(h.id))
: allHosts;
setHosts(filtered);
const withStatus = filtered.map((h) => ({
...h,
status:
(statuses as Record<number, { status: string }>)[h.id]?.status ??
h.status ??
"unknown",
}));
setHosts(withStatus);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, [hostIds.join(",")]);
};
// Align with global status cadence; pause when the tab is hidden.
usePageVisibleInterval(() => {
void fetchData();
}, 30_000);
useEffect(() => {
fetchData();
const iv = setInterval(fetchData, 10_000);
return () => clearInterval(iv);
}, [hostIds.join(",")]);
if (loading) {
return (
@@ -130,37 +130,10 @@ function HostStatusWidget({
};
poll();
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
// Align with global status cadence; metrics do not need sub-10s when hidden.
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
void poll();
}, 30_000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
void poll();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
const iv = setInterval(poll, 10000);
return () => {
cancelled = true;
stop();
document.removeEventListener("visibilitychange", onVisibility);
clearInterval(iv);
};
}, [hostId, needsMetrics]);

Some files were not shown because too many files have changed in this diff Show More