mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
This commit is contained in:
co-authored by
Chetan
Codebuff
Maxime Bonillo
ZacharyZcR
alex-ctms
Chetan Kumar
ZacharyZcR
dropafterfree
parent
566b908daf
commit
76fd9eedbf
@@ -0,0 +1,76 @@
|
|||||||
|
name: Deploy Helm
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
namespace:
|
||||||
|
description: "Kubernetes namespace"
|
||||||
|
required: true
|
||||||
|
default: termix
|
||||||
|
release:
|
||||||
|
description: "Helm release name"
|
||||||
|
required: true
|
||||||
|
default: termix
|
||||||
|
values_file:
|
||||||
|
description: "Values file to use"
|
||||||
|
required: true
|
||||||
|
default: charts/termix/values-gitops-example.yaml
|
||||||
|
image_tag:
|
||||||
|
description: "Image tag to deploy"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: production
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@v5
|
||||||
|
with:
|
||||||
|
version: v3.15.4
|
||||||
|
|
||||||
|
- name: Configure kubeconfig
|
||||||
|
env:
|
||||||
|
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||||
|
run: |
|
||||||
|
test -n "$KUBE_CONFIG"
|
||||||
|
echo "$KUBE_CONFIG" | base64 -d > "$RUNNER_TEMP/kubeconfig"
|
||||||
|
chmod 600 "$RUNNER_TEMP/kubeconfig"
|
||||||
|
|
||||||
|
- name: Lint chart
|
||||||
|
run: helm lint charts/termix
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
KUBECONFIG: ${{ runner.temp }}/kubeconfig
|
||||||
|
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||||
|
RELEASE_NAME: ${{ inputs.release }}
|
||||||
|
TARGET_NAMESPACE: ${{ inputs.namespace }}
|
||||||
|
VALUES_FILE: ${{ inputs.values_file }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "$RELEASE_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||||
|
[[ "$TARGET_NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||||
|
case "$VALUES_FILE" in
|
||||||
|
charts/termix/*.yaml) ;;
|
||||||
|
*) echo "values_file must be a YAML file under charts/termix" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
test -f "$VALUES_FILE"
|
||||||
|
ARGS=()
|
||||||
|
if [ -n "$IMAGE_TAG" ]; then
|
||||||
|
ARGS+=(--set "image.tag=$IMAGE_TAG")
|
||||||
|
fi
|
||||||
|
helm upgrade --install "$RELEASE_NAME" charts/termix \
|
||||||
|
--namespace "$TARGET_NAMESPACE" \
|
||||||
|
--create-namespace \
|
||||||
|
--values "$VALUES_FILE" \
|
||||||
|
--atomic \
|
||||||
|
--timeout 10m \
|
||||||
|
"${ARGS[@]}"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
name: Helm
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "charts/**"
|
||||||
|
- ".github/workflows/helm.yml"
|
||||||
|
- "deploy/**"
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "charts/**"
|
||||||
|
- ".github/workflows/helm.yml"
|
||||||
|
- "deploy/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: "Publish chart to GHCR OCI registry"
|
||||||
|
required: true
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@v5
|
||||||
|
with:
|
||||||
|
version: v3.15.4
|
||||||
|
|
||||||
|
- name: Lint chart
|
||||||
|
run: helm lint charts/termix
|
||||||
|
|
||||||
|
- name: Render default chart
|
||||||
|
run: helm template termix charts/termix --namespace termix
|
||||||
|
|
||||||
|
- name: Render GitOps example
|
||||||
|
run: helm template termix charts/termix --values charts/termix/values-gitops-example.yaml --namespace termix
|
||||||
|
|
||||||
|
- name: Render Traefik example
|
||||||
|
run: helm template termix charts/termix --values charts/termix/values-traefik.yaml --namespace termix
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: lint
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@v5
|
||||||
|
with:
|
||||||
|
version: v3.15.4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Package chart
|
||||||
|
run: helm package charts/termix --destination .helm-packages
|
||||||
|
|
||||||
|
- name: Push chart
|
||||||
|
run: |
|
||||||
|
OWNER="$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
helm push .helm-packages/termix-*.tgz "oci://ghcr.io/$OWNER/charts"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- local: deploy/gitlab/.gitlab-ci.yml
|
||||||
@@ -20,3 +20,6 @@ openapi.json
|
|||||||
|
|
||||||
# Generated by drizzle-kit; formatting is the tool's own
|
# Generated by drizzle-kit; formatting is the tool's own
|
||||||
drizzle/
|
drizzle/
|
||||||
|
|
||||||
|
# Helm templates contain Go template syntax, not plain YAML
|
||||||
|
charts/*/templates/
|
||||||
|
|||||||
@@ -313,6 +313,10 @@ Around 30 languages built in, managed through [Crowdin](https://docs.termix.site
|
|||||||
|
|
||||||
Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms.
|
Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms.
|
||||||
|
|
||||||
|
Deploying to Kubernetes? The Helm chart is in `charts/termix`, and setup instructions
|
||||||
|
covering Ingress, Traefik, Argo CD, GitHub Actions, and GitLab CI are at
|
||||||
|
[docs.termix.site/install/server/kubernetes](https://docs.termix.site/install/server/kubernetes).
|
||||||
|
|
||||||
Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features):
|
Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+40
-105
@@ -1,6 +1,6 @@
|
|||||||
<!-- SUMMARY -->
|
<!-- SUMMARY -->
|
||||||
|
|
||||||
Termix AI, automations, fleets, workspaces, subhosts, Proxmox metrics, a context aware terminal toolbar, split screen tabs, onboarding, PostgreSQL/MySQL support, and a large batch of fixes.
|
Credential cloning, a WSL local terminal, Helm and GitOps deployment, an editable file manager path bar, download progress bars, and a large batch of connection, sync, and remote desktop fixes.
|
||||||
|
|
||||||
<!-- /SUMMARY -->
|
<!-- /SUMMARY -->
|
||||||
|
|
||||||
@@ -12,114 +12,49 @@ https://youtu.be/lngaePO96tM
|
|||||||
|
|
||||||
<!-- UPDATE_LOG -->
|
<!-- UPDATE_LOG -->
|
||||||
|
|
||||||
- Added completely optional and disabled/removed by default Termix AI, an assistant that can work with your hosts and terminals
|
- Added the ability to clone an existing credential
|
||||||
- This feature was added based off a 60% (yes) to 40% (no) Discord vote.
|
- Added a WSL option for the local terminal
|
||||||
- The assistant cannot change anything on its own. It can only read a limited set of your Termix data and propose actions, and every change or command runs only after you approve it, using your own account and permissions.
|
- Added Helm charts and a GitOps deployment setup
|
||||||
- It has no access to credentials, SSH keys, vaults, users, roles, sessions, SSO, certificates, audit logs, or instance settings. Secrets are also stripped from anything sent to a model provider.
|
- Added an editable path bar to the file manager
|
||||||
- Both an admin and each user must turn it on before it does anything, and it stays off after upgrading.
|
- Added a progress bar for file downloads in the file manager
|
||||||
- Added automations with events, channels, and steps
|
- Added an identity file option so agent authentication stops after the right key
|
||||||
- Added a fleet system with snippets, packages, files, and inventory
|
- Added an environment variable to turn on silent OIDC login
|
||||||
- Added workspaces to save and restore your tab layout
|
- Added editable model settings for AI providers
|
||||||
- Added subhosts so hosts can be organized under a parent host
|
- Improved tmux monitor performance when aggregating sessions
|
||||||
- Added Proxmox metrics integration
|
- Improved Linux packaging with standard icon sizes
|
||||||
- Added a context aware terminal toolbar with quick links, host info, image pasting, and a movable desktop layout
|
|
||||||
- Added interactive terminal macros
|
|
||||||
- Added first-class split screen tabs
|
|
||||||
- Added a file manager trash instead of permanent deletes
|
|
||||||
- Added a local terminal to the desktop app
|
|
||||||
- Added inheritable connection defaults so hosts can share settings
|
|
||||||
- Added an onboarding flow with an interface simplicity system
|
|
||||||
- Added PostgreSQL and MySQL support alongside SQLite
|
|
||||||
- Added a redesigned host and credential sidebar with synced preferences and drag-to-reorder
|
|
||||||
- Added the option to open some app rail tabs as their own tab or in a right sidebar
|
|
||||||
- Added folder select to host multi select
|
|
||||||
- Added connection logs for RDP, VNC, and Telnet hosts
|
|
||||||
- Added native RDP launching on Windows desktop
|
|
||||||
- Added a drive file browser and drag-and-drop upload for RDP
|
|
||||||
- Added terminal image handoff so images open on your local machine
|
|
||||||
- Added custom terminal font selection
|
|
||||||
- Added trusted proxy authentication
|
|
||||||
- Added global touch input settings
|
|
||||||
- Added adaptive transfers that pick the fastest route and verify integrity
|
|
||||||
- Added adaptive polling and preloading that respond to activity and network cost
|
|
||||||
- Added adaptive SSH local echo for high latency connections
|
|
||||||
- Added custom disk and network metric options
|
|
||||||
- Added the ability to exclude specific mounts from disk usage metrics
|
|
||||||
- Added expanded snippet options
|
|
||||||
- Added downloadable session logs as text files
|
|
||||||
- Added keyboard shortcuts to move between open tabs
|
|
||||||
- Added Discord webhook notification channels
|
|
||||||
- Added paste support when not running over HTTPS
|
|
||||||
- Added Headscale API key and custom API endpoint support
|
|
||||||
- Added a Meta key option for terminals
|
|
||||||
- Added BE-AZERTY keyboard layout for remote desktop
|
|
||||||
- Added PKCE to the OIDC login flow
|
|
||||||
- Added Proxmox VMID and Docker tags to discovered guests
|
|
||||||
- Added custom SSL certificate support in admin settings
|
|
||||||
- Greatly improved performance across metrics polling and host management for large setups
|
|
||||||
|
|
||||||
<!-- /UPDATE_LOG -->
|
<!-- /UPDATE_LOG -->
|
||||||
|
|
||||||
<!-- BUG_FIXES -->
|
<!-- BUG_FIXES -->
|
||||||
|
|
||||||
- Periodic SSH terminal stalls caused by SQLite telemetry writes
|
- Passkey sign in not showing up on the login screen
|
||||||
- Missing OPKSSH binary breaking installs without internet access
|
- Connections through jump hosts failing
|
||||||
- Session recording writes slowing down terminals
|
- Jump hosts and remote desktop hosts not resolving after a sync
|
||||||
- SGR mouse tracking escape codes printing as text
|
- Malformed websocket messages crashing the server
|
||||||
- Terminal display distortion with special characters
|
- Encrypted file manager keys not prompting for a passphrase
|
||||||
- Windows Ctrl+W not closing the active tab
|
- SSH agent forwarding not working with the in-memory agent
|
||||||
- Tray Quit not terminating the desktop app
|
- Two factor prompts rejecting codes longer than six digits
|
||||||
- Mobile terminal scrollback not matching xterm wheel behavior
|
- OIDC lockout with no way to recover from environment settings
|
||||||
- tmux breaking on UTF-8 paths
|
- Tailscale requests failing on some setups
|
||||||
- Sudo password auto-fill not persisting
|
- Terminal clipboard shortcuts not working on non-QWERTY layouts
|
||||||
- SSH and sudo passwords not being saved or auto-filled
|
- Rapid mobile terminal input being sent one keystroke at a time
|
||||||
- Switching SSH authentication away from Vault failing
|
- HTTPS not being able to share the configured port
|
||||||
- Host edits being discarded without a warning
|
- Portable imports failing on remote databases
|
||||||
- Quick-created credentials not being selected
|
- Host status not showing when metrics collection is off
|
||||||
- Saved RDP connection settings not being preserved
|
- Proxmox guest credential usernames being wrong
|
||||||
- RDP domain credentials not being prompted for
|
- File drops not working for RDP in the browser
|
||||||
- Windows key mapping in remote desktop sessions
|
- Duplicate Docker HTTPS listener on startup
|
||||||
- VNC failing to connect to macOS screen sharing
|
- Remote sync server probe ignoring the certificate setting
|
||||||
- Mouse input breaking on touch-capable devices in RDP and VNC
|
- Runtime SSL settings not being preserved
|
||||||
- Docker runtime selection not persisting, plus Docker manager UI issues
|
- Automation notifications missing host details
|
||||||
- Desktop Docker console WebSocket not being authenticated
|
- Connection screens crashing outside the connection log provider
|
||||||
- Folders intermittently disappearing from duplicate requests
|
- better-sqlite3 failing in Docker on some platforms
|
||||||
- Folder deletion not refreshing the host list
|
- Host action rows shifting at large font sizes
|
||||||
- Proxmox guest identity being lost on edit
|
- Sidebar height jumping when hosts or credentials have tags
|
||||||
- Long host names shifting dashboard metrics
|
- Gaps between host rows in the sidebar list
|
||||||
- Host list rows resizing unexpectedly
|
- Rounded corners on the host list search bar
|
||||||
- Metrics collection all firing at once on startup
|
- Image storage settings text wrapping to one word per line
|
||||||
- Session activity writes hitting the database too often
|
- Unclear wording on the click-to-expand host setting
|
||||||
- Reachable and available hosts being treated the same
|
- Dragging a folder into the file manager failing to upload
|
||||||
- SSH keepalives could not be disabled
|
|
||||||
- OIDC group claims from multiple sources not being merged
|
|
||||||
- OIDC discovery issuers with trailing slashes failing
|
|
||||||
- LDAP logins not using preferred_username
|
|
||||||
- Trusted MFA devices not being bound to a specific client install
|
|
||||||
- 2FA could not be disabled with a single credential
|
|
||||||
- Profile API keys not being shown after creation
|
|
||||||
- SSH agent authentication being unclear in the host editor
|
|
||||||
- Tunnel status stream not requiring authentication
|
|
||||||
- SFTP and Docker console accepting a mismatched host id
|
|
||||||
- SSH connections whose host id resolved elsewhere being accepted
|
|
||||||
- User-managed CA certificates not being applied over SFTP
|
|
||||||
- Already-shared hosts losing their SSH authentication
|
|
||||||
- Real client IP not being captured for SSH login alerts behind a reverse proxy
|
|
||||||
- Audit log IPs not using the real client IP
|
|
||||||
- Homepage System Overview update indicator never firing
|
|
||||||
- Webhook notification channels not working
|
|
||||||
- Remote sync failing behind an nginx proxy
|
|
||||||
- First server sync not refreshing the UI
|
|
||||||
- Desktop app not showing update prompts and hiding the version badge
|
|
||||||
- Desktop Tailscale configuration being lost
|
|
||||||
- Command palette not loading new activity, plus Enter now opens the first result
|
|
||||||
- Database connection failures during login not being reported clearly
|
|
||||||
- audit_logs.user_id not being nullable on fresh SQLite installs
|
|
||||||
- Sync upserts writing to the wrong row
|
|
||||||
- Database migration failures on tables without an id column
|
|
||||||
- ssh_credentials rebuilds not matching the live schema
|
|
||||||
- Sidebar reset and fullscreen buttons sharing the same icon
|
|
||||||
- Host list icons not matching the tab bar icons
|
|
||||||
- Keep Linux credential storage working on unrecognized desktops
|
|
||||||
|
|
||||||
<!-- /BUG_FIXES -->
|
<!-- /BUG_FIXES -->
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.tgz
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: termix
|
||||||
|
description: Self-hosted SSH and remote desktop management for Kubernetes.
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: 2.7.0
|
||||||
|
home: https://github.com/Termix-SSH/Termix
|
||||||
|
sources:
|
||||||
|
- https://github.com/Termix-SSH/Termix
|
||||||
|
maintainers:
|
||||||
|
- name: Termix maintainers
|
||||||
|
keywords:
|
||||||
|
- ssh
|
||||||
|
- remote-desktop
|
||||||
|
- guacamole
|
||||||
|
- terminal
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Termix has been installed.
|
||||||
|
|
||||||
|
Service:
|
||||||
|
{{ include "termix.fullname" . }}:{{ .Values.service.port }}
|
||||||
|
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
Ingress hosts:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- {{ .host }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if .Values.traefik.ingressRoute.enabled }}
|
||||||
|
Traefik IngressRoute:
|
||||||
|
https://{{ .Values.traefik.ingressRoute.host }}{{ .Values.traefik.ingressRoute.pathPrefix }}
|
||||||
|
{{- else }}
|
||||||
|
Port-forward for local testing:
|
||||||
|
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "termix.fullname" . }} 8080:{{ .Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{- define "termix.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride -}}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||||
|
{{- if contains $name .Release.Name -}}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "termix.chart" . }}
|
||||||
|
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
{{- default (include "termix.fullname" .) .Values.serviceAccount.name -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- default "default" .Values.serviceAccount.name -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "termix.secretName" -}}
|
||||||
|
{{- default (printf "%s-secret" (include "termix.fullname" .)) .Values.secrets.name -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
strategy:
|
||||||
|
{{- toYaml .Values.strategy | nindent 4 }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
{{- include "termix.selectorLabels" . | nindent 8 }}
|
||||||
|
{{- with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: {{ include "termix.serviceAccountName" . }}
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
containers:
|
||||||
|
- name: termix
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
{{- range $key, $value := .Values.env }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
value: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.guacd.enabled }}
|
||||||
|
- name: GUACD_HOST
|
||||||
|
value: "127.0.0.1"
|
||||||
|
{{- else if .Values.guacd.host }}
|
||||||
|
- name: GUACD_HOST
|
||||||
|
value: {{ .Values.guacd.host | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: GUACD_TUNNEL_HOST
|
||||||
|
value: {{ include "termix.fullname" . | quote }}
|
||||||
|
{{- if ne .Values.database.dialect "sqlite" }}
|
||||||
|
- name: DATABASE_DIALECT
|
||||||
|
value: {{ .Values.database.dialect | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.database.existingSecret.name }}
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.database.existingSecret.name }}
|
||||||
|
key: {{ .Values.database.existingSecret.urlKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.secrets.create }}
|
||||||
|
{{- range $key, $value := .Values.secrets.data }}
|
||||||
|
{{- if $value }}
|
||||||
|
- name: {{ $key }}
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "termix.secretName" $ }}
|
||||||
|
key: {{ $key }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.extraEnv }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.extraEnvFrom }}
|
||||||
|
envFrom:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.liveness.enabled }}
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.probes.readiness.enabled }}
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||||
|
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||||
|
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
|
||||||
|
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /app/data
|
||||||
|
{{- if .Values.guacd.enabled }}
|
||||||
|
- name: guacd
|
||||||
|
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.guacd.image.pullPolicy }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.guacd.securityContext | nindent 12 }}
|
||||||
|
ports:
|
||||||
|
- name: guacd
|
||||||
|
containerPort: {{ .Values.guacd.service.port }}
|
||||||
|
protocol: TCP
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.guacd.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /termix-data
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
{{- if .Values.persistence.enabled }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ default (printf "%s-data" (include "termix.fullname" .)) .Values.persistence.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{- if .Values.autoscaling.enabled -}}
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{{- if .Values.ingress.enabled -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.className }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "termix.fullname" $ }}
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- if .Values.networkPolicy.enabled -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||||
|
policyTypes:
|
||||||
|
- Ingress
|
||||||
|
- Egress
|
||||||
|
ingress:
|
||||||
|
{{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
|
||||||
|
egress:
|
||||||
|
{{- toYaml .Values.networkPolicy.egress | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.podDisruptionBudget.enabled -}}
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}-data
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.persistence.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.size | quote }}
|
||||||
|
{{- if .Values.persistence.storageClass }}
|
||||||
|
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{- if .Values.secrets.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.secretName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{{- range $key, $value := .Values.secrets.data }}
|
||||||
|
{{- if $value }}
|
||||||
|
{{ $key }}: {{ $value | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.service.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "termix.selectorLabels" . | nindent 4 }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{{- if .Values.traefik.ingressRoute.enabled -}}
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: {{ include "termix.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "termix.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.traefik.ingressRoute.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
{{- toYaml .Values.traefik.ingressRoute.entryPoints | nindent 4 }}
|
||||||
|
routes:
|
||||||
|
- match: Host(`{{ .Values.traefik.ingressRoute.host }}`) && PathPrefix(`{{ .Values.traefik.ingressRoute.pathPrefix }}`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: {{ include "termix.fullname" . }}
|
||||||
|
port: {{ .Values.service.port }}
|
||||||
|
{{- with .Values.traefik.ingressRoute.middlewares }}
|
||||||
|
middlewares:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.traefik.ingressRoute.tls.enabled }}
|
||||||
|
tls:
|
||||||
|
{{- if .Values.traefik.ingressRoute.tls.secretName }}
|
||||||
|
secretName: {{ .Values.traefik.ingressRoute.tls.secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.traefik.ingressRoute.tls.certResolver }}
|
||||||
|
certResolver: {{ .Values.traefik.ingressRoute.tls.certResolver }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{{- if and .Values.ingress.enabled .Values.traefik.ingressRoute.enabled -}}
|
||||||
|
{{- fail "ingress and traefik.ingressRoute cannot both be enabled" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if and (eq .Values.database.dialect "sqlite") (or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled) -}}
|
||||||
|
{{- fail "SQLite supports only one replica; use Postgres or MySQL before enabling replicas or autoscaling" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if and (not .Values.guacd.enabled) (not .Values.guacd.host) -}}
|
||||||
|
{{- fail "guacd.host is required when the bundled guacd sidecar is disabled" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if and .Values.database.existingSecret.name .Values.secrets.create .Values.secrets.data.DATABASE_URL -}}
|
||||||
|
{{- fail "DATABASE_URL must come from either database.existingSecret or secrets.data, not both" -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
image:
|
||||||
|
repository: ghcr.io/termix-ssh/termix
|
||||||
|
tag: "2.7.0"
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: termix.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: termix-tls
|
||||||
|
hosts:
|
||||||
|
- termix.example.com
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 20Gi
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
create: false
|
||||||
|
|
||||||
|
database:
|
||||||
|
dialect: sqlite
|
||||||
|
|
||||||
|
extraEnvFrom: []
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
ingressRoute:
|
||||||
|
enabled: true
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
host: termix.example.com
|
||||||
|
pathPrefix: /
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
certResolver: letsencrypt
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 20Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 250m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/termix-ssh/termix
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
tag: ""
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
create: true
|
||||||
|
automount: false
|
||||||
|
annotations: {}
|
||||||
|
name: ""
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
|
||||||
|
securityContext: {}
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8080
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: termix.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
ingressRoute:
|
||||||
|
enabled: false
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
host: termix.example.com
|
||||||
|
pathPrefix: /
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
secretName: ""
|
||||||
|
certResolver: ""
|
||||||
|
middlewares: []
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 3
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: ""
|
||||||
|
annotations: {}
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
|
env:
|
||||||
|
PORT: "8080"
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
NODE_ENV: production
|
||||||
|
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
||||||
|
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
||||||
|
|
||||||
|
extraEnv: []
|
||||||
|
extraEnvFrom: []
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
create: false
|
||||||
|
name: ""
|
||||||
|
data:
|
||||||
|
JWT_SECRET: ""
|
||||||
|
DATABASE_URL: ""
|
||||||
|
GUACAMOLE_ENCRYPTION_KEY: ""
|
||||||
|
|
||||||
|
database:
|
||||||
|
dialect: sqlite
|
||||||
|
existingSecret:
|
||||||
|
name: ""
|
||||||
|
urlKey: DATABASE_URL
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
enabled: true
|
||||||
|
host: ""
|
||||||
|
image:
|
||||||
|
repository: guacamole/guacd
|
||||||
|
tag: 1.6.0
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
resources: {}
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
service:
|
||||||
|
port: 4822
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
tolerations: []
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
|
||||||
|
probes:
|
||||||
|
liveness:
|
||||||
|
enabled: true
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
timeoutSeconds: 10
|
||||||
|
failureThreshold: 3
|
||||||
|
readiness:
|
||||||
|
enabled: true
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 6
|
||||||
|
|
||||||
|
podDisruptionBudget:
|
||||||
|
enabled: false
|
||||||
|
minAvailable: 1
|
||||||
|
|
||||||
|
networkPolicy:
|
||||||
|
enabled: false
|
||||||
|
ingress: []
|
||||||
|
egress: []
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: termix-traefik
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||||
|
targetRevision: main
|
||||||
|
path: charts/termix
|
||||||
|
helm:
|
||||||
|
releaseName: termix
|
||||||
|
valueFiles:
|
||||||
|
- values-traefik.yaml
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: termix
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
- PruneLast=true
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: termix
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||||
|
targetRevision: main
|
||||||
|
path: charts/termix
|
||||||
|
helm:
|
||||||
|
releaseName: termix
|
||||||
|
valueFiles:
|
||||||
|
- values-gitops-example.yaml
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: termix
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
- PruneLast=true
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- package
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
DOCKER_TLS_CERTDIR: "/certs"
|
||||||
|
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
|
||||||
|
HELM_EXPERIMENTAL_OCI: "1"
|
||||||
|
|
||||||
|
helm-lint:
|
||||||
|
image:
|
||||||
|
name: alpine/helm:3.15.4
|
||||||
|
entrypoint: [""]
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- helm lint charts/termix
|
||||||
|
- helm template termix charts/termix --values charts/termix/values-gitops-example.yaml >/tmp/termix.yaml
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
docker-build-push:
|
||||||
|
image: docker:27
|
||||||
|
stage: build
|
||||||
|
services:
|
||||||
|
- docker:27-dind
|
||||||
|
before_script:
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||||
|
script:
|
||||||
|
- docker build -f docker/Dockerfile -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
|
||||||
|
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
|
||||||
|
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
helm-package-push:
|
||||||
|
image:
|
||||||
|
name: alpine/helm:3.15.4
|
||||||
|
entrypoint: [""]
|
||||||
|
stage: package
|
||||||
|
before_script:
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | helm registry login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||||
|
script:
|
||||||
|
- helm dependency update charts/termix
|
||||||
|
- helm package charts/termix --destination .helm-packages
|
||||||
|
- helm push .helm-packages/termix-*.tgz "oci://$CI_REGISTRY_IMAGE/charts"
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- .helm-packages/
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
image:
|
||||||
|
name: alpine/helm:3.15.4
|
||||||
|
entrypoint: [""]
|
||||||
|
stage: deploy
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
before_script:
|
||||||
|
- echo "$KUBE_CONFIG" | base64 -d > kubeconfig
|
||||||
|
- chmod 600 kubeconfig
|
||||||
|
script:
|
||||||
|
- helm upgrade --install termix charts/termix --namespace termix --create-namespace --kubeconfig kubeconfig --set image.repository="$CI_REGISTRY_IMAGE" --set image.tag="$IMAGE_TAG" --values charts/termix/values-gitops-example.yaml --atomic --timeout 10m
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
when: manual
|
||||||
+7
-2
@@ -31,7 +31,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm rebuild better-sqlite3
|
RUN rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||||
|
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||||
|
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node
|
||||||
|
|
||||||
RUN npm run build:backend
|
RUN npm run build:backend
|
||||||
|
|
||||||
@@ -67,7 +69,10 @@ COPY scripts/patch-guacamole-lite.cjs ./scripts/
|
|||||||
|
|
||||||
RUN npm ci --omit=dev --ignore-scripts && \
|
RUN npm ci --omit=dev --ignore-scripts && \
|
||||||
node scripts/patch-guacamole-lite.cjs && \
|
node scripts/patch-guacamole-lite.cjs && \
|
||||||
npm rebuild better-sqlite3 bcryptjs ssh2 && \
|
rm -rf node_modules/better-sqlite3/prebuilds && \
|
||||||
|
npm run build-release --prefix node_modules/better-sqlite3 && \
|
||||||
|
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
|
||||||
|
npm rebuild bcryptjs ssh2 && \
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# Stage 6: Final optimized image
|
# Stage 6: Final optimized image
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ if [ "$(id -u)" = "0" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
DATA_DIR=${DATA_DIR:-/app/data}
|
DATA_DIR=${DATA_DIR:-/app/data}
|
||||||
|
|
||||||
|
RUNTIME_ENABLE_SSL_SET=${ENABLE_SSL+x}
|
||||||
|
RUNTIME_ENABLE_SSL=${ENABLE_SSL-}
|
||||||
|
RUNTIME_SSL_PORT_SET=${SSL_PORT+x}
|
||||||
|
RUNTIME_SSL_PORT=${SSL_PORT-}
|
||||||
|
RUNTIME_SSL_CERT_PATH_SET=${SSL_CERT_PATH+x}
|
||||||
|
RUNTIME_SSL_CERT_PATH=${SSL_CERT_PATH-}
|
||||||
|
RUNTIME_SSL_KEY_PATH_SET=${SSL_KEY_PATH+x}
|
||||||
|
RUNTIME_SSL_KEY_PATH=${SSL_KEY_PATH-}
|
||||||
|
RUNTIME_SSL_DOMAIN_SET=${SSL_DOMAIN+x}
|
||||||
|
RUNTIME_SSL_DOMAIN=${SSL_DOMAIN-}
|
||||||
|
|
||||||
if [ -f "$DATA_DIR/.env" ]; then
|
if [ -f "$DATA_DIR/.env" ]; then
|
||||||
echo "Loading persisted SSL settings from $DATA_DIR/.env"
|
echo "Loading persisted SSL settings from $DATA_DIR/.env"
|
||||||
set -a
|
set -a
|
||||||
@@ -30,11 +42,18 @@ if [ -f "$DATA_DIR/.env" ]; then
|
|||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[ "$RUNTIME_ENABLE_SSL_SET" = "x" ] && ENABLE_SSL=$RUNTIME_ENABLE_SSL
|
||||||
|
[ "$RUNTIME_SSL_PORT_SET" = "x" ] && SSL_PORT=$RUNTIME_SSL_PORT
|
||||||
|
[ "$RUNTIME_SSL_CERT_PATH_SET" = "x" ] && SSL_CERT_PATH=$RUNTIME_SSL_CERT_PATH
|
||||||
|
[ "$RUNTIME_SSL_KEY_PATH_SET" = "x" ] && SSL_KEY_PATH=$RUNTIME_SSL_KEY_PATH
|
||||||
|
[ "$RUNTIME_SSL_DOMAIN_SET" = "x" ] && SSL_DOMAIN=$RUNTIME_SSL_DOMAIN
|
||||||
|
|
||||||
export PORT=${PORT:-8080}
|
export PORT=${PORT:-8080}
|
||||||
export ENABLE_SSL=${ENABLE_SSL:-false}
|
export ENABLE_SSL=${ENABLE_SSL:-false}
|
||||||
export SSL_PORT=${SSL_PORT:-8443}
|
export SSL_PORT=${SSL_PORT:-8443}
|
||||||
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
|
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
|
||||||
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}
|
||||||
|
export TERMIX_SSL_TERMINATED_BY_NGINX=true
|
||||||
|
|
||||||
echo "Configuring web UI to run on port: $PORT"
|
echo "Configuring web UI to run on port: $PORT"
|
||||||
|
|
||||||
@@ -49,6 +68,11 @@ fi
|
|||||||
mkdir -p /tmp/nginx
|
mkdir -p /tmp/nginx
|
||||||
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
|
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
|
||||||
|
|
||||||
|
if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then
|
||||||
|
echo "HTTP and HTTPS use port $SSL_PORT; disabling the HTTP redirect listener"
|
||||||
|
sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
|
mkdir -p /app/data /app/uploads /app/data/.opk /app/data/acme-webroot/.well-known/acme-challenge
|
||||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -69,12 +69,14 @@ http {
|
|||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# BEGIN HTTP_REDIRECT_SERVER
|
||||||
server {
|
server {
|
||||||
listen ${PORT};
|
listen ${PORT};
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||||
}
|
}
|
||||||
|
# END HTTP_REDIRECT_SERVER
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen ${SSL_PORT} ssl;
|
listen ${SSL_PORT} ssl;
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
"arch": ["x64", "arm64", "armv7l"]
|
"arch": ["x64", "arm64", "armv7l"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "public/icon.png",
|
"icon": "public/icons",
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
"executableName": "termix",
|
"executableName": "termix",
|
||||||
"maintainer": "Termix <mail@termix.site>",
|
"maintainer": "Termix <mail@termix.site>",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
function resolveLocalShell(platform, requestedShell, env = process.env) {
|
||||||
|
if (platform === "win32") {
|
||||||
|
if (requestedShell === "wsl") {
|
||||||
|
return { file: "wsl.exe", args: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
||||||
|
args: ["-NoLogo"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
file:
|
||||||
|
env.TERMIX_LOCAL_SHELL ||
|
||||||
|
env.SHELL ||
|
||||||
|
(platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
|
||||||
|
args: ["-l"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveLocalShell };
|
||||||
+16
-20
@@ -30,25 +30,10 @@ const { launchNativeRdp } = require("./native-rdp.cjs");
|
|||||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||||
const { quitApp } = require("./app-quit.cjs");
|
const { quitApp } = require("./app-quit.cjs");
|
||||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||||
|
const { resolveLocalShell } = require("./local-shell.cjs");
|
||||||
|
|
||||||
const localTerminalSessions = new Map();
|
const localTerminalSessions = new Map();
|
||||||
|
|
||||||
function localShell() {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
return {
|
|
||||||
file: process.env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
|
||||||
args: ["-NoLogo"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
file:
|
|
||||||
process.env.TERMIX_LOCAL_SHELL ||
|
|
||||||
process.env.SHELL ||
|
|
||||||
(process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
|
|
||||||
args: ["-l"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ownedLocalTerminal(event, sessionId) {
|
function ownedLocalTerminal(event, sessionId) {
|
||||||
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -537,7 +522,11 @@ function httpFetch(url, options = {}) {
|
|||||||
method: options.method || "GET",
|
method: options.method || "GET",
|
||||||
headers: options.headers || {},
|
headers: options.headers || {},
|
||||||
timeout: options.timeout || 10000,
|
timeout: options.timeout || 10000,
|
||||||
...(isHttps ? getTlsVerificationOptions(url) : {}),
|
...(isHttps
|
||||||
|
? options.allowInvalidCertificate
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: getTlsVerificationOptions(url)
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = client.request(url, requestOptions, (res) => {
|
const req = client.request(url, requestOptions, (res) => {
|
||||||
@@ -2928,7 +2917,7 @@ ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
|
|||||||
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
||||||
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
const shellConfig = localShell();
|
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
||||||
const child = pty.spawn(shellConfig.file, shellConfig.args, {
|
const child = pty.spawn(shellConfig.file, shellConfig.args, {
|
||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
cols,
|
cols,
|
||||||
@@ -3134,7 +3123,11 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
async function testServerConnection(
|
||||||
|
_event,
|
||||||
|
serverUrl,
|
||||||
|
allowInvalidCertificate = false,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||||
const healthUrl = `${normalizedServerUrl}/health`;
|
const healthUrl = `${normalizedServerUrl}/health`;
|
||||||
@@ -3154,6 +3147,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|||||||
const response = await httpFetch(healthUrl, {
|
const response = await httpFetch(healthUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
|
allowInvalidCertificate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
@@ -3207,7 +3201,9 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("test-server-connection", testServerConnection);
|
||||||
|
|
||||||
function createMenu() {
|
function createMenu() {
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.116.0",
|
"@anthropic-ai/sdk": "^0.116.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"description": "Self-hosted SSH and remote desktop management.",
|
"description": "Self-hosted SSH and remote desktop management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ const patches = [
|
|||||||
{
|
{
|
||||||
file: "xterm.mjs",
|
file: "xterm.mjs",
|
||||||
replacements: [
|
replacements: [
|
||||||
|
[
|
||||||
[
|
[
|
||||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||||
],
|
],
|
||||||
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
'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=""',
|
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||||
@@ -45,19 +48,25 @@ const patches = [
|
|||||||
"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,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&&",
|
"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&&",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
[
|
[
|
||||||
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
|
||||||
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||||
],
|
],
|
||||||
|
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "xterm.js",
|
file: "xterm.js",
|
||||||
replacements: [
|
replacements: [
|
||||||
|
[
|
||||||
[
|
[
|
||||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||||
],
|
],
|
||||||
|
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
|
'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=""',
|
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
|
||||||
@@ -70,10 +79,13 @@ const patches = [
|
|||||||
"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,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&&",
|
"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&&",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
[
|
[
|
||||||
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
|
||||||
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||||
],
|
],
|
||||||
|
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -91,12 +103,14 @@ for (const { file, replacements } of patches) {
|
|||||||
if (source.includes(patched)) {
|
if (source.includes(patched)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!source.includes(original)) {
|
const originals = Array.isArray(original) ? original : [original];
|
||||||
|
const matched = originals.find((candidate) => source.includes(candidate));
|
||||||
|
if (!matched) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
source = source.replace(original, patched);
|
source = source.replace(matched, patched);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../types/automations.js";
|
} from "../../types/automations.js";
|
||||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../utils/logger.js";
|
||||||
|
import { resolveHostById } from "../hosts/host-resolver.js";
|
||||||
import { executeStep } from "./actions/index.js";
|
import { executeStep } from "./actions/index.js";
|
||||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||||
import { compare } from "./conditions.js";
|
import { compare } from "./conditions.js";
|
||||||
@@ -175,8 +176,33 @@ export class AutomationEngine {
|
|||||||
|
|
||||||
if (!claimed) this.running.add(automation.id);
|
if (!claimed) this.running.add(automation.id);
|
||||||
|
|
||||||
|
const trigger = { ...(request.triggerContext ?? {}) };
|
||||||
|
trigger.type ??= request.triggerType;
|
||||||
|
let host: TemplateContext["host"];
|
||||||
|
if (request.triggerHostId) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveHostById(
|
||||||
|
request.triggerHostId,
|
||||||
|
automation.userId,
|
||||||
|
);
|
||||||
|
if (resolved) {
|
||||||
|
host = {
|
||||||
|
id: request.triggerHostId,
|
||||||
|
name: resolved.name || resolved.ip,
|
||||||
|
ip: resolved.ip,
|
||||||
|
username: resolved.username,
|
||||||
|
port: resolved.port,
|
||||||
|
};
|
||||||
|
trigger.hostName ??= host.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A notification should still run with its numeric host id.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const template: TemplateContext = {
|
const template: TemplateContext = {
|
||||||
trigger: request.triggerContext ?? {},
|
host,
|
||||||
|
trigger,
|
||||||
steps: {},
|
steps: {},
|
||||||
vars: {},
|
vars: {},
|
||||||
run: {
|
run: {
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ async function sendWebhook(
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: notification.title,
|
title: notification.title,
|
||||||
|
hostName:
|
||||||
|
notification.context?.host?.name ??
|
||||||
|
notification.context?.trigger?.hostName,
|
||||||
|
hostId:
|
||||||
|
notification.context?.host?.id ?? notification.context?.trigger?.hostId,
|
||||||
|
ruleName: notification.title,
|
||||||
|
ruleId: notification.context?.run?.automationId,
|
||||||
|
triggerType: notification.context?.trigger?.type,
|
||||||
|
value: notification.context?.trigger?.value,
|
||||||
|
threshold: notification.context?.trigger?.threshold,
|
||||||
message: notification.body,
|
message: notification.body,
|
||||||
severity: notification.severity,
|
severity: notification.severity,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -2052,7 +2052,12 @@ if (sslConfig.enabled) {
|
|||||||
ssl_port: sslConfig.port,
|
ssl_port: sslConfig.port,
|
||||||
backend_http_port: HTTP_PORT,
|
backend_http_port: HTTP_PORT,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
sslConfig.enabled &&
|
||||||
|
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const httpsServer = https.createServer(
|
const httpsServer = https.createServer(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,21 +23,17 @@ export async function withSqliteForeignKeysDisabled<T>(
|
|||||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
* Backup restore writes tables in an order that is not dependency-safe, so the
|
||||||
* constraints have to stand down for the duration.
|
* constraints have to stand down for the duration.
|
||||||
*
|
*
|
||||||
* **This has no equivalent on Postgres or MySQL here.** Postgres needs
|
* Postgres and MySQL keep their constraints enabled. The portable importer
|
||||||
* superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is
|
* writes through repositories and handles individual row failures, so it must
|
||||||
* per-connection, which a pool does not guarantee. Rather than run the import
|
* still be allowed to run there; only SQLite needs this connection-local
|
||||||
* with constraints enforced and have it fail partway through — leaving a
|
* relaxation for legacy backups whose rows are not dependency ordered.
|
||||||
* half-restored database — it refuses with a message that says why.
|
|
||||||
*/
|
*/
|
||||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const dialect = resolveDatabaseDialect();
|
const dialect = resolveDatabaseDialect();
|
||||||
if (!needsExplicitPersist(dialect)) {
|
if (!needsExplicitPersist(dialect)) {
|
||||||
throw new Error(
|
return operation();
|
||||||
`Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` +
|
|
||||||
`Restore into the database directly with its own tooling instead.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||||
|
|||||||
@@ -388,6 +388,165 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /credentials/{id}/duplicate:
|
||||||
|
* post:
|
||||||
|
* summary: Duplicate a credential
|
||||||
|
* description: Creates a new credential from an existing one, optionally overriding fields (e.g. password), leaving the original credential untouched.
|
||||||
|
* tags:
|
||||||
|
* - Credentials
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* username:
|
||||||
|
* type: string
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* key:
|
||||||
|
* type: string
|
||||||
|
* keyPassword:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 201:
|
||||||
|
* description: New credential created from the duplicate.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request.
|
||||||
|
* 404:
|
||||||
|
* description: Credential not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to duplicate credential.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/duplicate",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
const { name, username, password, key, keyPassword, certPublicKey } =
|
||||||
|
req.body ?? {};
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !id) {
|
||||||
|
authLogger.warn("Invalid request for credential duplicate");
|
||||||
|
return res.status(400).json({ error: "Invalid request" });
|
||||||
|
}
|
||||||
|
if (!isNonEmptyString(name)) {
|
||||||
|
return res.status(400).json({ error: "Name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialId = parseInt(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const source = await credentialRepository.findDecryptedByIdForUser(
|
||||||
|
userId,
|
||||||
|
credentialId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authType = source.authType;
|
||||||
|
const plainPassword =
|
||||||
|
password !== undefined ? password || null : source.password;
|
||||||
|
const plainKey = key !== undefined ? key || null : source.key;
|
||||||
|
const plainKeyPassword =
|
||||||
|
keyPassword !== undefined ? keyPassword || null : source.keyPassword;
|
||||||
|
|
||||||
|
let keyInfo = null;
|
||||||
|
if (authType === "key" && plainKey) {
|
||||||
|
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||||
|
if (!keyInfo.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: keyInfo.error
|
||||||
|
? `Invalid SSH key: ${keyInfo.error}`
|
||||||
|
: "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialData = {
|
||||||
|
userId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: source.description,
|
||||||
|
folder: source.folder,
|
||||||
|
tags: source.tags,
|
||||||
|
authType,
|
||||||
|
username:
|
||||||
|
username !== undefined ? username?.trim() || null : source.username,
|
||||||
|
password: authType === "password" ? plainPassword : null,
|
||||||
|
key: authType === "key" ? plainKey : null,
|
||||||
|
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
|
||||||
|
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
|
||||||
|
keyPassword: authType === "key" ? plainKeyPassword : null,
|
||||||
|
keyType: source.keyType,
|
||||||
|
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
|
||||||
|
certPublicKey:
|
||||||
|
authType === "key"
|
||||||
|
? certPublicKey !== undefined
|
||||||
|
? certPublicKey?.trim() || null
|
||||||
|
: source.certPublicKey
|
||||||
|
: null,
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsed: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await credentialRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
credentialData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ipAddress: dupIp, userAgent: dupUa } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: await getAuditUsername(userId),
|
||||||
|
action: "duplicate_credential",
|
||||||
|
resourceType: "credential",
|
||||||
|
resourceId: String(created.id),
|
||||||
|
resourceName: name,
|
||||||
|
ipAddress: dupIp,
|
||||||
|
userAgent: dupUa,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
authLogger.success(
|
||||||
|
`SSH credential duplicated: ${name} (from ${credentialId}) by user ${userId}`,
|
||||||
|
{
|
||||||
|
operation: "credential_duplicate_success",
|
||||||
|
userId,
|
||||||
|
sourceCredentialId: credentialId,
|
||||||
|
credentialId: created.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(formatCredentialOutput(created));
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to duplicate credential", err, {
|
||||||
|
operation: "credential_duplicate",
|
||||||
|
userId,
|
||||||
|
credentialId,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
error: getErrorMessage(err, "Failed to duplicate credential"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /credentials/{id}:
|
* /credentials/{id}:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function resolveProxmoxImportAuth(
|
|||||||
return {
|
return {
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId,
|
credentialId,
|
||||||
overrideCredentialUsername: 1,
|
overrideCredentialUsername: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,12 @@ async function syncProxmoxHost(
|
|||||||
? existing.connectionType
|
? existing.connectionType
|
||||||
: null;
|
: null;
|
||||||
const connectionType = existingConnectionType ?? guest.connectionType;
|
const connectionType = existingConnectionType ?? guest.connectionType;
|
||||||
|
const usesImportCredential =
|
||||||
|
connectionType === "ssh" && importAuth.authType === "credential";
|
||||||
|
const existingUsesImportCredential =
|
||||||
|
usesImportCredential &&
|
||||||
|
existing?.authType === "credential" &&
|
||||||
|
existing?.credentialId === importAuth.credentialId;
|
||||||
const port =
|
const port =
|
||||||
typeof existing?.port === "number"
|
typeof existing?.port === "number"
|
||||||
? existing.port
|
? existing.port
|
||||||
@@ -696,7 +702,9 @@ async function syncProxmoxHost(
|
|||||||
? 3389
|
? 3389
|
||||||
: 22;
|
: 22;
|
||||||
const username =
|
const username =
|
||||||
typeof existing?.username === "string" && existing.username
|
usesImportCredential && (!existing || existingUsesImportCredential)
|
||||||
|
? ""
|
||||||
|
: typeof existing?.username === "string" && existing.username
|
||||||
? existing.username
|
? existing.username
|
||||||
: connectionType === "rdp"
|
: connectionType === "rdp"
|
||||||
? ""
|
? ""
|
||||||
@@ -714,6 +722,10 @@ async function syncProxmoxHost(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existingUsesImportCredential) {
|
||||||
|
update.credentialId = importAuth.credentialId;
|
||||||
|
update.overrideCredentialUsername = false;
|
||||||
|
}
|
||||||
await createCurrentHostRepository().updateEncryptedForUser(
|
await createCurrentHostRepository().updateEncryptedForUser(
|
||||||
userId,
|
userId,
|
||||||
existing.id as number,
|
existing.id as number,
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { authLogger } from "../../utils/logger.js";
|
|||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import type { SSOProviderType } from "../../../types/index.js";
|
import type { SSOProviderType } from "../../../types/index.js";
|
||||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||||
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
import {
|
||||||
|
getOIDCConfigFromEnv,
|
||||||
|
isOIDCEnvOverrideEnabled,
|
||||||
|
} from "./user-oidc-utils.js";
|
||||||
import {
|
import {
|
||||||
decryptSsoConfigSecrets,
|
decryptSsoConfigSecrets,
|
||||||
encryptSsoConfigSecrets,
|
encryptSsoConfigSecrets,
|
||||||
@@ -18,6 +21,19 @@ function isOidcLike(type: SSOProviderType): boolean {
|
|||||||
return type === "oidc" || type === "github" || type === "google";
|
return type === "oidc" || type === "github" || type === "google";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidOidcIssuer(value: unknown): boolean {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
["http:", "https:"].includes(url.protocol) &&
|
||||||
|
!/\/userinfo\/?$/i.test(url.pathname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,13 +111,19 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
*/
|
*/
|
||||||
router.get("/sso-providers", async (_req, res) => {
|
router.get("/sso-providers", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
|
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||||
|
return res.json([
|
||||||
|
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const providers =
|
const providers =
|
||||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||||
|
|
||||||
// If no DB providers exist, synthesize one from env vars so SSO login
|
// If no DB providers exist, synthesize one from env vars so SSO login
|
||||||
// remains available when configured purely via environment variables.
|
// remains available when configured purely via environment variables.
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
const envConfig = getOIDCConfigFromEnv();
|
|
||||||
if (envConfig) {
|
if (envConfig) {
|
||||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||||
}
|
}
|
||||||
@@ -222,6 +244,12 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(type === "github" || type === "google") &&
|
(type === "github" || type === "google") &&
|
||||||
(!c.client_id || !c.client_secret)
|
(!c.client_id || !c.client_secret)
|
||||||
@@ -353,6 +381,16 @@ export function registerSSOProviderRoutes(router: Router): void {
|
|||||||
),
|
),
|
||||||
...rawConfig,
|
...rawConfig,
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
isOidcLike(effectiveType) &&
|
||||||
|
mergedConfig.issuer_url &&
|
||||||
|
!isValidOidcIssuer(mergedConfig.issuer_url)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error:
|
||||||
|
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||||
|
});
|
||||||
|
}
|
||||||
encryptedConfig = await encryptProviderConfig(
|
encryptedConfig = await encryptProviderConfig(
|
||||||
mergedConfig,
|
mergedConfig,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type Router as ExpressRouter,
|
type Router as ExpressRouter,
|
||||||
} from "express";
|
} from "express";
|
||||||
import { apiLogger } from "../../utils/logger.js";
|
import { apiLogger } from "../../utils/logger.js";
|
||||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
import { fetchWithProxy } from "../../utils/proxy-agent.js";
|
||||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||||
|
|
||||||
interface TailscaleDevice {
|
interface TailscaleDevice {
|
||||||
@@ -74,12 +74,11 @@ export function registerTailscaleRoutes(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
||||||
const response = await fetch(url, {
|
const response = await fetchWithProxy(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
"User-Agent": "Termix/1.0",
|
"User-Agent": "Termix/1.0",
|
||||||
},
|
},
|
||||||
dispatcher: getFetchDispatcher(url),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||||
|
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||||
* whether they emit bare names (`devops-interns`) or full paths
|
* whether they emit bare names (`devops-interns`) or full paths
|
||||||
@@ -438,6 +442,11 @@ export async function loadProviderConfig(
|
|||||||
providerType: SSOProviderType;
|
providerType: SSOProviderType;
|
||||||
providerDbId: number | null;
|
providerDbId: number | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
|
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||||
|
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||||
|
}
|
||||||
|
|
||||||
if (providerId != null) {
|
if (providerId != null) {
|
||||||
try {
|
try {
|
||||||
const row =
|
const row =
|
||||||
@@ -485,7 +494,6 @@ export async function loadProviderConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: env vars
|
// Fallback: env vars
|
||||||
const envConfig = getOIDCConfigFromEnv();
|
|
||||||
if (envConfig) {
|
if (envConfig) {
|
||||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||||
}
|
}
|
||||||
@@ -542,6 +550,14 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
|||||||
providerDbId: number | null;
|
providerDbId: number | null;
|
||||||
} | null> {
|
} | null> {
|
||||||
const target = normalizeIssuer(issuer);
|
const target = normalizeIssuer(issuer);
|
||||||
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
|
if (
|
||||||
|
envConfig?.issuer_url &&
|
||||||
|
isOIDCEnvOverrideEnabled() &&
|
||||||
|
normalizeIssuer(envConfig.issuer_url) === target
|
||||||
|
) {
|
||||||
|
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
||||||
@@ -569,7 +585,6 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const envConfig = getOIDCConfigFromEnv();
|
|
||||||
if (
|
if (
|
||||||
envConfig?.issuer_url &&
|
envConfig?.issuer_url &&
|
||||||
normalizeIssuer(envConfig.issuer_url) === target
|
normalizeIssuer(envConfig.issuer_url) === target
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ function isPasswordResetAllowed(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOidcSilentLoginDefaultFromEnv(): boolean | undefined {
|
||||||
|
const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT;
|
||||||
|
if (envVal === undefined) return undefined;
|
||||||
|
return envVal.trim().toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function isNativeAppRequest(req: Request): boolean {
|
function isNativeAppRequest(req: Request): boolean {
|
||||||
return (
|
return (
|
||||||
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
(req.get("User-Agent") || "").startsWith("Termix-Mobile/") ||
|
||||||
@@ -2460,7 +2466,7 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
|||||||
* /users/oidc-silent-login-default:
|
* /users/oidc-silent-login-default:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get OIDC silent login default setting
|
* summary: Get OIDC silent login default setting
|
||||||
* description: Returns whether silent OIDC login is enabled as the default behavior.
|
* description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||||
* tags:
|
* tags:
|
||||||
* - Users
|
* - Users
|
||||||
* responses:
|
* responses:
|
||||||
@@ -2471,11 +2477,17 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/oidc-silent-login-default", async (_req, res) => {
|
router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const envVal = getOidcSilentLoginDefaultFromEnv();
|
||||||
|
if (envVal !== undefined) {
|
||||||
|
res.json({ enabled: envVal, locked: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||||
"oidc_silent_login_default",
|
"oidc_silent_login_default",
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
locked: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to get OIDC silent login default", err);
|
authLogger.error("Failed to get OIDC silent login default", err);
|
||||||
@@ -2507,6 +2519,8 @@ router.get("/oidc-silent-login-default", async (_req, res) => {
|
|||||||
* description: Invalid value.
|
* description: Invalid value.
|
||||||
* 403:
|
* 403:
|
||||||
* description: Not authorized.
|
* description: Not authorized.
|
||||||
|
* 409:
|
||||||
|
* description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Failed to update setting.
|
* description: Failed to update setting.
|
||||||
*/
|
*/
|
||||||
@@ -2520,6 +2534,12 @@ router.patch(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
}
|
}
|
||||||
|
if (getOidcSilentLoginDefaultFromEnv() !== undefined) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error:
|
||||||
|
"OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here",
|
||||||
|
});
|
||||||
|
}
|
||||||
const { enabled } = req.body;
|
const { enabled } = req.body;
|
||||||
if (typeof enabled !== "boolean") {
|
if (typeof enabled !== "boolean") {
|
||||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createCorsMiddleware } from "../../utils/cors-config.js";
|
|||||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import ssh2Pkg, { Client as SSHClient } from "ssh2";
|
||||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||||
import { fileLogger } from "../../utils/logger.js";
|
import { fileLogger } from "../../utils/logger.js";
|
||||||
@@ -42,8 +42,11 @@ import {
|
|||||||
} from "./transfer-engine.js";
|
} from "./transfer-engine.js";
|
||||||
import { registerFileContentRoutes } from "./content-routes.js";
|
import { registerFileContentRoutes } from "./content-routes.js";
|
||||||
import { createConnectionLog } from "../connection-log.js";
|
import { createConnectionLog } from "../connection-log.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js";
|
||||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
import {
|
||||||
|
isPrivateKeyPassphraseError,
|
||||||
|
preparePrivateKeyForSSH2,
|
||||||
|
} from "../../utils/ssh-key-utils.js";
|
||||||
import {
|
import {
|
||||||
ChannelOpenSerializer,
|
ChannelOpenSerializer,
|
||||||
execChannel,
|
execChannel,
|
||||||
@@ -840,7 +843,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: resolvedHost.password,
|
password: resolvedHost.password,
|
||||||
sshKey: resolvedHost.key,
|
sshKey: resolvedHost.key,
|
||||||
keyPassword: resolvedHost.keyPassword,
|
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||||
@@ -909,7 +912,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
resolvedCredentials = {
|
resolvedCredentials = {
|
||||||
password: resolvedHost.password,
|
password: resolvedHost.password,
|
||||||
sshKey: resolvedHost.key,
|
sshKey: resolvedHost.key,
|
||||||
keyPassword: resolvedHost.keyPassword,
|
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||||
authType: resolvedHost.authType,
|
authType: resolvedHost.authType,
|
||||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||||
@@ -1049,6 +1052,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
resolvedCredentials.keyPassword,
|
resolvedCredentials.keyPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parsedKey = ssh2Pkg.utils.parseKey(
|
||||||
|
config.privateKey as Buffer,
|
||||||
|
resolvedCredentials.keyPassword,
|
||||||
|
);
|
||||||
|
if (parsedKey instanceof Error) throw parsedKey;
|
||||||
|
|
||||||
if (resolvedCredentials.keyPassword)
|
if (resolvedCredentials.keyPassword)
|
||||||
config.passphrase = resolvedCredentials.keyPassword;
|
config.passphrase = resolvedCredentials.keyPassword;
|
||||||
|
|
||||||
@@ -1071,6 +1080,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (keyError) {
|
} catch (keyError) {
|
||||||
|
if (isPrivateKeyPassphraseError(keyError)) {
|
||||||
|
return res.json({ status: "passphrase_required", connectionLogs });
|
||||||
|
}
|
||||||
|
|
||||||
fileLogger.error("SSH key format error for file manager", {
|
fileLogger.error("SSH key format error for file manager", {
|
||||||
operation: "file_connect",
|
operation: "file_connect",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1832,7 +1845,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "Failed to connect through jump hosts",
|
error:
|
||||||
|
error instanceof JumpHostChainError
|
||||||
|
? `Failed to connect through jump hosts: ${error.message}`
|
||||||
|
: "Failed to connect through jump hosts",
|
||||||
connectionLogs,
|
connectionLogs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||||
|
|
||||||
|
export const SFTP_OPEN_READ = 0x00000001;
|
||||||
|
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||||
|
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||||
|
|
||||||
|
export function promisifySftpStat(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
): Promise<import("ssh2").Stats> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.stat(path, (err, stats) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(stats);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpUnlink(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.unlink(path, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpRmdir(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.rmdir(path, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpMkdir(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
mode: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.mkdir(path, { mode }, (err) => {
|
||||||
|
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpChmod(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
mode: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.chmod(path, mode, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpReaddir(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.readdir(path, (err, list) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(list);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpOpen(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
flags: number,
|
||||||
|
mode: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.open(path, flags, mode, (err, handle) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(handle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpClose(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
handle: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.close(handle, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promisifySftpFstat(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
handle: Buffer,
|
||||||
|
): Promise<import("ssh2").Stats> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
sftp.fstat(handle, (err, stats) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(stats);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { networkInterfaces } from "os";
|
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import type { ClientChannel } from "ssh2";
|
import type { ClientChannel } from "ssh2";
|
||||||
import { fileLogger } from "../../utils/logger.js";
|
import { fileLogger } from "../../utils/logger.js";
|
||||||
import {
|
import {
|
||||||
basename,
|
basename,
|
||||||
buildPathFromSegments,
|
|
||||||
dirname,
|
dirname,
|
||||||
getWorkingDir,
|
getWorkingDir,
|
||||||
inferPlatformFromPath,
|
inferPlatformFromPath,
|
||||||
@@ -14,7 +12,6 @@ import {
|
|||||||
normalizeSftpPath,
|
normalizeSftpPath,
|
||||||
pathsOverlap,
|
pathsOverlap,
|
||||||
sftpPathToLocalPath,
|
sftpPathToLocalPath,
|
||||||
splitPathSegments,
|
|
||||||
type TransferPlatform,
|
type TransferPlatform,
|
||||||
} from "../transfer-paths.js";
|
} from "../transfer-paths.js";
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +23,60 @@ import {
|
|||||||
type TransferScanSummary,
|
type TransferScanSummary,
|
||||||
} from "./transfer-routing.js";
|
} from "./transfer-routing.js";
|
||||||
import { verifySftpFileIntegrity } from "./transfer-integrity.js";
|
import { verifySftpFileIntegrity } from "./transfer-integrity.js";
|
||||||
|
import {
|
||||||
|
promisifySftpChmod,
|
||||||
|
promisifySftpClose,
|
||||||
|
promisifySftpFstat,
|
||||||
|
promisifySftpOpen,
|
||||||
|
promisifySftpStat,
|
||||||
|
promisifySftpUnlink,
|
||||||
|
} from "./sftp-promisify.js";
|
||||||
|
import {
|
||||||
|
buildTransferHopTimings,
|
||||||
|
computeTransferMbPerSec,
|
||||||
|
createEmptyXferStats,
|
||||||
|
createHopWallClock,
|
||||||
|
createThrottledProgress,
|
||||||
|
elapsedMs,
|
||||||
|
hopSpanMs,
|
||||||
|
mergeXferStats,
|
||||||
|
noteHopEnd,
|
||||||
|
noteHopStart,
|
||||||
|
type PipelinedXferStats,
|
||||||
|
type TransferTimings,
|
||||||
|
} from "./transfer-stats.js";
|
||||||
|
import {
|
||||||
|
TransferCancelledError,
|
||||||
|
TransferStalledError,
|
||||||
|
isRecoverableTransferError,
|
||||||
|
} from "./transfer-errors.js";
|
||||||
|
import {
|
||||||
|
escapeShell,
|
||||||
|
isLocalSshEndpoint,
|
||||||
|
isPermissionError,
|
||||||
|
isRootOnlyPath,
|
||||||
|
} from "./transfer-host-utils.js";
|
||||||
|
import {
|
||||||
|
SFTP_OPEN_READ,
|
||||||
|
SFTP_OPEN_WRITE,
|
||||||
|
SFTP_OPEN_WRITE_RESUME,
|
||||||
|
} from "./sftp-promisify.js";
|
||||||
|
import {
|
||||||
|
collectFileWorkItems,
|
||||||
|
readSftpSample,
|
||||||
|
type FileWorkItem,
|
||||||
|
} from "./transfer-scan.js";
|
||||||
|
import {
|
||||||
|
deletePathSftp,
|
||||||
|
ensureDirectoryTreeSftp,
|
||||||
|
} from "./transfer-sftp-dir.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_PARALLEL_SEGMENT_COUNT,
|
||||||
|
SFTP_XFER_SEGMENT_SIZE,
|
||||||
|
buildSegmentCopyJobs,
|
||||||
|
clampParallelSegmentCount,
|
||||||
|
type SegmentCopyJob,
|
||||||
|
} from "./transfer-segment-copy.js";
|
||||||
import {
|
import {
|
||||||
buildDirectProbeCommand,
|
buildDirectProbeCommand,
|
||||||
buildDirectRsyncCommand,
|
buildDirectRsyncCommand,
|
||||||
@@ -107,31 +158,11 @@ export type TransferStatus =
|
|||||||
"running" | "success" | "partial" | "error" | "cancelled";
|
"running" | "success" | "partial" | "error" | "cancelled";
|
||||||
export type TransferMethod = "stream" | "tar" | "item_sftp" | "direct_rsync";
|
export type TransferMethod = "stream" | "tar" | "item_sftp" | "direct_rsync";
|
||||||
|
|
||||||
export type TransferHopId =
|
export type {
|
||||||
"source_read" | "dest_sftp_write" | "dest_local_write";
|
TransferHopId,
|
||||||
|
TransferHopMetrics,
|
||||||
export interface TransferHopMetrics {
|
TransferTimings,
|
||||||
id: TransferHopId;
|
} from "./transfer-stats.js";
|
||||||
bytes: number;
|
|
||||||
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
|
||||||
spanMs: number;
|
|
||||||
mbPerSec: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransferTimings {
|
|
||||||
prepareDestMs?: number;
|
|
||||||
compressMs?: number;
|
|
||||||
transferMs?: number;
|
|
||||||
extractMs?: number;
|
|
||||||
verifyMs?: number;
|
|
||||||
directBenchmarkMs?: number;
|
|
||||||
relayBenchmarkMs?: number;
|
|
||||||
sourceDeleteMs?: number;
|
|
||||||
totalMs?: number;
|
|
||||||
transferBytes?: number;
|
|
||||||
endToEndMbPerSec?: number;
|
|
||||||
hops?: TransferHopMetrics[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransferProgress {
|
export interface TransferProgress {
|
||||||
transferId: string;
|
transferId: string;
|
||||||
@@ -204,34 +235,6 @@ interface ActiveXferControl {
|
|||||||
const activeXferControls = new Map<string, ActiveXferControl>();
|
const activeXferControls = new Map<string, ActiveXferControl>();
|
||||||
const cancelWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();
|
const cancelWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
class TransferCancelledError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Transfer cancelled");
|
|
||||||
this.name = "TransferCancelledError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TransferStalledError extends Error {
|
|
||||||
readonly byteOffset?: number;
|
|
||||||
readonly segmentIndex?: number;
|
|
||||||
|
|
||||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
|
||||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
|
||||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
|
||||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
|
||||||
this.name = "TransferStalledError";
|
|
||||||
this.byteOffset = byteOffset;
|
|
||||||
this.segmentIndex = segmentIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TransferConnectionLostError extends Error {
|
|
||||||
constructor(message = "Transfer SSH connection lost") {
|
|
||||||
super(message);
|
|
||||||
this.name = "TransferConnectionLostError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function throwIfCancelled(transferId: string): void {
|
function throwIfCancelled(transferId: string): void {
|
||||||
if (cancelRequestedTransfers.has(transferId)) {
|
if (cancelRequestedTransfers.has(transferId)) {
|
||||||
throw new TransferCancelledError();
|
throw new TransferCancelledError();
|
||||||
@@ -315,8 +318,6 @@ const SMALL_FILE_SYNC_THRESHOLD = 10 * 1024 * 1024;
|
|||||||
const SFTP_XFER_CHUNK_SIZE = 256 * 1024;
|
const SFTP_XFER_CHUNK_SIZE = 256 * 1024;
|
||||||
/** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */
|
/** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */
|
||||||
const SFTP_XFER_CONCURRENCY = 32;
|
const SFTP_XFER_CONCURRENCY = 32;
|
||||||
/** Reset pipelined scheduler every segment to avoid long-run deadlocks at GiB boundaries. */
|
|
||||||
const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
|
||||||
/** Files above this size use segmented copy; smaller files use a single scheduler run. */
|
/** Files above this size use segmented copy; smaller files use a single scheduler run. */
|
||||||
const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024;
|
const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024;
|
||||||
/** Per-segment attempts before giving up (sequential and parallel). */
|
/** Per-segment attempts before giving up (sequential and parallel). */
|
||||||
@@ -328,17 +329,9 @@ const SFTP_SEQUENTIAL_COPY_MAX_ATTEMPTS = 2;
|
|||||||
const SFTP_PARALLEL_COPY_MAX_ATTEMPTS = 2;
|
const SFTP_PARALLEL_COPY_MAX_ATTEMPTS = 2;
|
||||||
/** Short backoff before opening fresh dedicated SSH sessions. */
|
/** Short backoff before opening fresh dedicated SSH sessions. */
|
||||||
const TRANSFER_SESSION_RESET_DELAYS_MS = [1000, 2000, 3000];
|
const TRANSFER_SESSION_RESET_DELAYS_MS = [1000, 2000, 3000];
|
||||||
const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
|
||||||
const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
|
||||||
const TRANSFER_HANDLE_CLOSE_TIMEOUT_MS = 2500;
|
const TRANSFER_HANDLE_CLOSE_TIMEOUT_MS = 2500;
|
||||||
const HUNG_TRANSFER_MS = 90_000;
|
const HUNG_TRANSFER_MS = 90_000;
|
||||||
const HUNG_RECONNECTING_MS = 180_000;
|
const HUNG_RECONNECTING_MS = 180_000;
|
||||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
|
||||||
const SFTP_OPEN_READ = 0x00000001;
|
|
||||||
/** WRITE | CREATE | TRUNCATE — new file or overwrite from start. */
|
|
||||||
const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
|
||||||
/** READ | WRITE | CREATE — resume into an existing partial file without truncating. */
|
|
||||||
const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
|
||||||
|
|
||||||
interface TransferReconnectContext {
|
interface TransferReconnectContext {
|
||||||
deps: HostTransferDeps;
|
deps: HostTransferDeps;
|
||||||
@@ -364,31 +357,6 @@ function buildTransferReconnectContext(
|
|||||||
return { deps, transferId, ...meta };
|
return { deps, transferId, ...meta };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecoverableTransferConnectionError(err: unknown): boolean {
|
|
||||||
if (!(err instanceof Error)) return false;
|
|
||||||
const msg = err.message.toLowerCase();
|
|
||||||
return (
|
|
||||||
msg.includes("no response from server") ||
|
|
||||||
msg.includes("connection lost") ||
|
|
||||||
msg.includes("not connected") ||
|
|
||||||
msg.includes("econnreset") ||
|
|
||||||
msg.includes("econnrefused") ||
|
|
||||||
msg.includes("etimedout") ||
|
|
||||||
msg.includes("socket hang up") ||
|
|
||||||
msg.includes("protocol error") ||
|
|
||||||
msg.includes("connection closed") ||
|
|
||||||
msg.includes("channel open failure")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecoverableTransferError(err: unknown): boolean {
|
|
||||||
return (
|
|
||||||
err instanceof TransferStalledError ||
|
|
||||||
err instanceof TransferConnectionLostError ||
|
|
||||||
isRecoverableTransferConnectionError(err)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeDestResumeOffset(
|
async function probeDestResumeOffset(
|
||||||
destSftp: SFTPWrapper,
|
destSftp: SFTPWrapper,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
@@ -560,69 +528,6 @@ async function resetDedicatedTransferSessions(
|
|||||||
return { sourceSession, destSession, sourceSftp, destSftp };
|
return { sourceSession, destSession, sourceSftp, destSftp };
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedLocalAddresses: Set<string> | null = null;
|
|
||||||
|
|
||||||
function normalizeHostAddress(host: string): string {
|
|
||||||
const trimmed = host.trim().toLowerCase();
|
|
||||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
||||||
return trimmed.slice(1, -1);
|
|
||||||
}
|
|
||||||
return trimmed.split(":")[0] ?? trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalAddresses(): Set<string> {
|
|
||||||
if (cachedLocalAddresses) return cachedLocalAddresses;
|
|
||||||
|
|
||||||
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
||||||
for (const ifaces of Object.values(networkInterfaces())) {
|
|
||||||
if (!ifaces) continue;
|
|
||||||
for (const iface of ifaces) {
|
|
||||||
if (!iface.internal && iface.family === "IPv4") {
|
|
||||||
addresses.add(iface.address.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cachedLocalAddresses = addresses;
|
|
||||||
return addresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocalSshEndpoint(ip?: string): boolean {
|
|
||||||
if (!ip) return false;
|
|
||||||
const bare = normalizeHostAddress(ip);
|
|
||||||
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return getLocalAddresses().has(bare);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
|
||||||
let pending = 0;
|
|
||||||
let lastFlush = 0;
|
|
||||||
|
|
||||||
const flush = () => {
|
|
||||||
if (pending > 0) {
|
|
||||||
onProgress?.(pending);
|
|
||||||
pending = 0;
|
|
||||||
lastFlush = Date.now();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
add(bytes: number) {
|
|
||||||
pending += bytes;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
flush,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeShell(s: string): string {
|
|
||||||
return s.replace(/'/g, "'\"'\"'");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectTransferPlatform(
|
async function detectTransferPlatform(
|
||||||
deps: HostTransferDeps,
|
deps: HostTransferDeps,
|
||||||
session: SSHSessionLike,
|
session: SSHSessionLike,
|
||||||
@@ -680,150 +585,6 @@ async function detectTransferPlatform(
|
|||||||
return "unix";
|
return "unix";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRootOnlyPath(path: string): boolean {
|
|
||||||
const normalized = normalizeSftpPath(path);
|
|
||||||
return (
|
|
||||||
normalized === "/" ||
|
|
||||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
|
||||||
/^[A-Za-z]:$/.test(normalized)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPermissionError(err: Error): boolean {
|
|
||||||
const msg = err.message.toLowerCase();
|
|
||||||
return (
|
|
||||||
msg.includes("permission denied") ||
|
|
||||||
msg.includes("eacces") ||
|
|
||||||
msg.includes("access denied")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpStat(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
): Promise<import("ssh2").Stats> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.stat(path, (err, stats) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(stats);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpUnlink(sftp: SFTPWrapper, path: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.unlink(path, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpRmdir(sftp: SFTPWrapper, path: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.rmdir(path, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureDirectoryTreeSftp(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
dirPath: string,
|
|
||||||
created: Set<string> = new Set(),
|
|
||||||
): Promise<void> {
|
|
||||||
const normalized = normalizeSftpPath(dirPath);
|
|
||||||
if (!normalized || isRootOnlyPath(normalized)) return;
|
|
||||||
|
|
||||||
const { root, segments } = splitPathSegments(normalized);
|
|
||||||
if (segments.length === 0) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const current = buildPathFromSegments(root, segments, i + 1);
|
|
||||||
if (created.has(current)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await promisifySftpMkdir(sftp, current, 0o755);
|
|
||||||
} catch (err) {
|
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
|
||||||
if (code !== "EEXIST") {
|
|
||||||
try {
|
|
||||||
const stats = await promisifySftpStat(sftp, current);
|
|
||||||
if (!stats.isDirectory()) throw err;
|
|
||||||
} catch {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
created.add(current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePathSftp(sftp: SFTPWrapper, path: string): Promise<void> {
|
|
||||||
let stats: import("ssh2").Stats;
|
|
||||||
try {
|
|
||||||
stats = await promisifySftpStat(sftp, path);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
|
||||||
const entries = await promisifySftpReaddir(sftp, path);
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.filename === "." || entry.filename === "..") continue;
|
|
||||||
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
|
||||||
}
|
|
||||||
await promisifySftpRmdir(sftp, path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
await promisifySftpUnlink(sftp, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpMkdir(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
mode: number,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.mkdir(path, { mode }, (err) => {
|
|
||||||
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpChmod(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
mode: number,
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.chmod(path, mode, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpReaddir(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.readdir(path, (err, list) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(list);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function execCommand(
|
function execCommand(
|
||||||
deps: HostTransferDeps,
|
deps: HostTransferDeps,
|
||||||
session: SSHSessionLike,
|
session: SSHSessionLike,
|
||||||
@@ -1157,10 +918,6 @@ function finalizeTransfer(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function elapsedMs(start: number): number {
|
|
||||||
return Date.now() - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyTransferredFile(
|
async function verifyTransferredFile(
|
||||||
deps: HostTransferDeps,
|
deps: HostTransferDeps,
|
||||||
transferId: string,
|
transferId: string,
|
||||||
@@ -1208,103 +965,6 @@ async function verifyTransferredFile(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeTransferMbPerSec(
|
|
||||||
bytes: number,
|
|
||||||
ms: number,
|
|
||||||
): number | undefined {
|
|
||||||
if (ms <= 0 || bytes <= 0) return undefined;
|
|
||||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HopWallClock {
|
|
||||||
firstAt: number | null;
|
|
||||||
lastAt: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHopWallClock(): HopWallClock {
|
|
||||||
return { firstAt: null, lastAt: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function noteHopStart(
|
|
||||||
clock: HopWallClock,
|
|
||||||
t: number = performance.now(),
|
|
||||||
): void {
|
|
||||||
if (clock.firstAt === null) clock.firstAt = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function noteHopEnd(clock: HopWallClock, t: number = performance.now()): void {
|
|
||||||
clock.lastAt = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hopSpanMs(clock: HopWallClock): number {
|
|
||||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
|
||||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PipelinedXferStats {
|
|
||||||
bytes: number;
|
|
||||||
sourceReadSpanMs: number;
|
|
||||||
destWriteSpanMs: number;
|
|
||||||
destWriteKind: "sftp" | "local";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyXferStats(): PipelinedXferStats {
|
|
||||||
return {
|
|
||||||
bytes: 0,
|
|
||||||
sourceReadSpanMs: 0,
|
|
||||||
destWriteSpanMs: 0,
|
|
||||||
destWriteKind: "sftp",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeXferStats(
|
|
||||||
target: PipelinedXferStats,
|
|
||||||
source: PipelinedXferStats,
|
|
||||||
): void {
|
|
||||||
target.bytes += source.bytes;
|
|
||||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
|
||||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
|
||||||
target.destWriteKind = source.destWriteKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTransferHopTimings(
|
|
||||||
stats: PipelinedXferStats,
|
|
||||||
transferMs: number,
|
|
||||||
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
|
||||||
const hops: TransferHopMetrics[] = [];
|
|
||||||
|
|
||||||
const sourceRate = computeTransferMbPerSec(
|
|
||||||
stats.bytes,
|
|
||||||
stats.sourceReadSpanMs,
|
|
||||||
);
|
|
||||||
if (sourceRate !== undefined) {
|
|
||||||
hops.push({
|
|
||||||
id: "source_read",
|
|
||||||
bytes: stats.bytes,
|
|
||||||
spanMs: stats.sourceReadSpanMs,
|
|
||||||
mbPerSec: sourceRate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const destHopId: TransferHopId =
|
|
||||||
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
|
||||||
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
|
||||||
if (destRate !== undefined) {
|
|
||||||
hops.push({
|
|
||||||
id: destHopId,
|
|
||||||
bytes: stats.bytes,
|
|
||||||
spanMs: stats.destWriteSpanMs,
|
|
||||||
mbPerSec: destRate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
transferBytes: stats.bytes,
|
|
||||||
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
|
||||||
hops,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSourcePathsAfterSuccess(
|
async function deleteSourcePathsAfterSuccess(
|
||||||
deps: HostTransferDeps,
|
deps: HostTransferDeps,
|
||||||
transferId: string,
|
transferId: string,
|
||||||
@@ -1334,63 +994,6 @@ async function ensureDestParentForFile(
|
|||||||
await ensureDestDirectory(deps, destSession, parent);
|
await ensureDestDirectory(deps, destSession, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileWorkItem {
|
|
||||||
sourcePath: string;
|
|
||||||
destPath: string;
|
|
||||||
mode: number;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectFileWorkItems(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
sourcePath: string,
|
|
||||||
destRoot: string,
|
|
||||||
destBaseName?: string,
|
|
||||||
): Promise<FileWorkItem[]> {
|
|
||||||
const stats = await promisifySftpStat(sftp, sourcePath);
|
|
||||||
const name = destBaseName ?? basename(sourcePath);
|
|
||||||
const destPath = joinPath(destRoot, name);
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sourcePath,
|
|
||||||
destPath,
|
|
||||||
mode: stats.mode & 0o7777,
|
|
||||||
size: stats.size,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: FileWorkItem[] = [];
|
|
||||||
const walk = async (srcDir: string, dstDir: string) => {
|
|
||||||
const entries = await promisifySftpReaddir(sftp, srcDir);
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.filename === "." || entry.filename === "..") continue;
|
|
||||||
const srcChild = joinPath(srcDir, entry.filename);
|
|
||||||
const dstChild = joinPath(dstDir, entry.filename);
|
|
||||||
|
|
||||||
if (entry.attrs.isDirectory()) {
|
|
||||||
await walk(srcChild, dstChild);
|
|
||||||
} else if (entry.attrs.isFile()) {
|
|
||||||
items.push({
|
|
||||||
sourcePath: srcChild,
|
|
||||||
destPath: dstChild,
|
|
||||||
mode: entry.attrs.mode & 0o7777,
|
|
||||||
size: entry.attrs.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await walk(sourcePath, destPath);
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scanSourcePathsForRouting(
|
async function scanSourcePathsForRouting(
|
||||||
sftp: SFTPWrapper,
|
sftp: SFTPWrapper,
|
||||||
sourcePaths: string[],
|
sourcePaths: string[],
|
||||||
@@ -1430,63 +1033,6 @@ async function scanSourcePathsForRouting(
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readSftpSample(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
fileSize: number,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
const sampleSize = Math.min(64 * 1024, fileSize);
|
|
||||||
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
|
||||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.alloc(sampleSize);
|
|
||||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
|
||||||
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(count);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return buffer.subarray(0, bytesRead);
|
|
||||||
} finally {
|
|
||||||
await promisifySftpClose(sftp, handle).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpOpen(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
path: string,
|
|
||||||
flags: number,
|
|
||||||
mode: number,
|
|
||||||
): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.open(path, flags, mode, (err, handle) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(handle);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpClose(sftp: SFTPWrapper, handle: Buffer): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.close(handle, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function promisifySftpFstat(
|
|
||||||
sftp: SFTPWrapper,
|
|
||||||
handle: Buffer,
|
|
||||||
): Promise<import("ssh2").Stats> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
sftp.fstat(handle, (err, stats) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(stats);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PipelinedXferOptions {
|
interface PipelinedXferOptions {
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
initialOffset?: number;
|
initialOffset?: number;
|
||||||
@@ -1500,43 +1046,6 @@ interface PipelinedXferOptions {
|
|||||||
onResumeOffset?: (offset: number) => void;
|
onResumeOffset?: (offset: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SegmentCopyJob {
|
|
||||||
offset: number;
|
|
||||||
length: number;
|
|
||||||
segmentIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampParallelSegmentCount(value?: number): number {
|
|
||||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
|
||||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSegmentCopyJobs(
|
|
||||||
fileSize: number,
|
|
||||||
initialOffset: number,
|
|
||||||
destResumeSize: number,
|
|
||||||
): SegmentCopyJob[] {
|
|
||||||
const jobs: SegmentCopyJob[] = [];
|
|
||||||
for (
|
|
||||||
let offset = initialOffset;
|
|
||||||
offset < fileSize;
|
|
||||||
offset += SFTP_XFER_SEGMENT_SIZE
|
|
||||||
) {
|
|
||||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
|
||||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
|
||||||
if (destResumeSize >= offset + length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
|
||||||
jobs.push({
|
|
||||||
offset: start,
|
|
||||||
length: offset + length - start,
|
|
||||||
segmentIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jobs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAllTransferSessions(
|
function closeAllTransferSessions(
|
||||||
deps: HostTransferDeps,
|
deps: HostTransferDeps,
|
||||||
ctx: TransferReconnectContext,
|
ctx: TransferReconnectContext,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export class TransferCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Transfer cancelled");
|
||||||
|
this.name = "TransferCancelledError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferStalledError extends Error {
|
||||||
|
readonly byteOffset?: number;
|
||||||
|
readonly segmentIndex?: number;
|
||||||
|
|
||||||
|
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||||
|
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||||
|
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||||
|
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||||
|
this.name = "TransferStalledError";
|
||||||
|
this.byteOffset = byteOffset;
|
||||||
|
this.segmentIndex = segmentIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransferConnectionLostError extends Error {
|
||||||
|
constructor(message = "Transfer SSH connection lost") {
|
||||||
|
super(message);
|
||||||
|
this.name = "TransferConnectionLostError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||||
|
if (!(err instanceof Error)) return false;
|
||||||
|
const msg = err.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("no response from server") ||
|
||||||
|
msg.includes("connection lost") ||
|
||||||
|
msg.includes("not connected") ||
|
||||||
|
msg.includes("econnreset") ||
|
||||||
|
msg.includes("econnrefused") ||
|
||||||
|
msg.includes("etimedout") ||
|
||||||
|
msg.includes("socket hang up") ||
|
||||||
|
msg.includes("protocol error") ||
|
||||||
|
msg.includes("connection closed") ||
|
||||||
|
msg.includes("channel open failure")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecoverableTransferError(err: unknown): boolean {
|
||||||
|
return (
|
||||||
|
err instanceof TransferStalledError ||
|
||||||
|
err instanceof TransferConnectionLostError ||
|
||||||
|
isRecoverableTransferConnectionError(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { networkInterfaces } from "os";
|
||||||
|
import { normalizeSftpPath } from "../transfer-paths.js";
|
||||||
|
|
||||||
|
let cachedLocalAddresses: Set<string> | null = null;
|
||||||
|
|
||||||
|
export function normalizeHostAddress(host: string): string {
|
||||||
|
const trimmed = host.trim().toLowerCase();
|
||||||
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed.split(":")[0] ?? trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalAddresses(): Set<string> {
|
||||||
|
if (cachedLocalAddresses) return cachedLocalAddresses;
|
||||||
|
|
||||||
|
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||||
|
for (const ifaces of Object.values(networkInterfaces())) {
|
||||||
|
if (!ifaces) continue;
|
||||||
|
for (const iface of ifaces) {
|
||||||
|
if (!iface.internal && iface.family === "IPv4") {
|
||||||
|
addresses.add(iface.address.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedLocalAddresses = addresses;
|
||||||
|
return addresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalSshEndpoint(ip?: string): boolean {
|
||||||
|
if (!ip) return false;
|
||||||
|
const bare = normalizeHostAddress(ip);
|
||||||
|
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return getLocalAddresses().has(bare);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeShell(s: string): string {
|
||||||
|
return s.replace(/'/g, "'\"'\"'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRootOnlyPath(path: string): boolean {
|
||||||
|
const normalized = normalizeSftpPath(path);
|
||||||
|
return (
|
||||||
|
normalized === "/" ||
|
||||||
|
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||||
|
/^[A-Za-z]:$/.test(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPermissionError(err: Error): boolean {
|
||||||
|
const msg = err.message.toLowerCase();
|
||||||
|
return (
|
||||||
|
msg.includes("permission denied") ||
|
||||||
|
msg.includes("eacces") ||
|
||||||
|
msg.includes("access denied")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { basename, joinPath } from "../transfer-paths.js";
|
||||||
|
import {
|
||||||
|
SFTP_OPEN_READ,
|
||||||
|
promisifySftpClose,
|
||||||
|
promisifySftpOpen,
|
||||||
|
promisifySftpReaddir,
|
||||||
|
promisifySftpStat,
|
||||||
|
} from "./sftp-promisify.js";
|
||||||
|
|
||||||
|
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||||
|
|
||||||
|
export interface FileWorkItem {
|
||||||
|
sourcePath: string;
|
||||||
|
destPath: string;
|
||||||
|
mode: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectFileWorkItems(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
sourcePath: string,
|
||||||
|
destRoot: string,
|
||||||
|
destBaseName?: string,
|
||||||
|
): Promise<FileWorkItem[]> {
|
||||||
|
const stats = await promisifySftpStat(sftp, sourcePath);
|
||||||
|
const name = destBaseName ?? basename(sourcePath);
|
||||||
|
const destPath = joinPath(destRoot, name);
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sourcePath,
|
||||||
|
destPath,
|
||||||
|
mode: stats.mode & 0o7777,
|
||||||
|
size: stats.size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: FileWorkItem[] = [];
|
||||||
|
const walk = async (srcDir: string, dstDir: string) => {
|
||||||
|
const entries = await promisifySftpReaddir(sftp, srcDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.filename === "." || entry.filename === "..") continue;
|
||||||
|
const srcChild = joinPath(srcDir, entry.filename);
|
||||||
|
const dstChild = joinPath(dstDir, entry.filename);
|
||||||
|
|
||||||
|
if (entry.attrs.isDirectory()) {
|
||||||
|
await walk(srcChild, dstChild);
|
||||||
|
} else if (entry.attrs.isFile()) {
|
||||||
|
items.push({
|
||||||
|
sourcePath: srcChild,
|
||||||
|
destPath: dstChild,
|
||||||
|
mode: entry.attrs.mode & 0o7777,
|
||||||
|
size: entry.attrs.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await walk(sourcePath, destPath);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSftpSample(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
fileSize: number,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const sampleSize = Math.min(64 * 1024, fileSize);
|
||||||
|
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
||||||
|
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(sampleSize);
|
||||||
|
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||||
|
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(count);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return buffer.subarray(0, bytesRead);
|
||||||
|
} finally {
|
||||||
|
await promisifySftpClose(sftp, handle).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||||
|
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||||
|
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||||
|
|
||||||
|
export interface SegmentCopyJob {
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampParallelSegmentCount(value?: number): number {
|
||||||
|
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||||
|
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSegmentCopyJobs(
|
||||||
|
fileSize: number,
|
||||||
|
initialOffset: number,
|
||||||
|
destResumeSize: number,
|
||||||
|
): SegmentCopyJob[] {
|
||||||
|
const jobs: SegmentCopyJob[] = [];
|
||||||
|
for (
|
||||||
|
let offset = initialOffset;
|
||||||
|
offset < fileSize;
|
||||||
|
offset += SFTP_XFER_SEGMENT_SIZE
|
||||||
|
) {
|
||||||
|
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||||
|
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||||
|
if (destResumeSize >= offset + length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||||
|
jobs.push({
|
||||||
|
offset: start,
|
||||||
|
length: offset + length - start,
|
||||||
|
segmentIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
buildPathFromSegments,
|
||||||
|
joinPath,
|
||||||
|
normalizeSftpPath,
|
||||||
|
splitPathSegments,
|
||||||
|
} from "../transfer-paths.js";
|
||||||
|
import { isRootOnlyPath } from "./transfer-host-utils.js";
|
||||||
|
import {
|
||||||
|
promisifySftpMkdir,
|
||||||
|
promisifySftpReaddir,
|
||||||
|
promisifySftpRmdir,
|
||||||
|
promisifySftpStat,
|
||||||
|
promisifySftpUnlink,
|
||||||
|
} from "./sftp-promisify.js";
|
||||||
|
|
||||||
|
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||||
|
|
||||||
|
export async function ensureDirectoryTreeSftp(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
dirPath: string,
|
||||||
|
created: Set<string> = new Set(),
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeSftpPath(dirPath);
|
||||||
|
if (!normalized || isRootOnlyPath(normalized)) return;
|
||||||
|
|
||||||
|
const { root, segments } = splitPathSegments(normalized);
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const current = buildPathFromSegments(root, segments, i + 1);
|
||||||
|
if (created.has(current)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promisifySftpMkdir(sftp, current, 0o755);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code !== "EEXIST") {
|
||||||
|
try {
|
||||||
|
const stats = await promisifySftpStat(sftp, current);
|
||||||
|
if (!stats.isDirectory()) throw err;
|
||||||
|
} catch {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created.add(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePathSftp(
|
||||||
|
sftp: SFTPWrapper,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let stats: import("ssh2").Stats;
|
||||||
|
try {
|
||||||
|
stats = await promisifySftpStat(sftp, path);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const entries = await promisifySftpReaddir(sftp, path);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.filename === "." || entry.filename === "..") continue;
|
||||||
|
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
||||||
|
}
|
||||||
|
await promisifySftpRmdir(sftp, path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
await promisifySftpUnlink(sftp, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { performance } from "node:perf_hooks";
|
||||||
|
|
||||||
|
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
export type TransferHopId =
|
||||||
|
"source_read" | "dest_sftp_write" | "dest_local_write";
|
||||||
|
|
||||||
|
export interface TransferHopMetrics {
|
||||||
|
id: TransferHopId;
|
||||||
|
bytes: number;
|
||||||
|
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
||||||
|
spanMs: number;
|
||||||
|
mbPerSec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransferTimings {
|
||||||
|
prepareDestMs?: number;
|
||||||
|
compressMs?: number;
|
||||||
|
transferMs?: number;
|
||||||
|
extractMs?: number;
|
||||||
|
verifyMs?: number;
|
||||||
|
directBenchmarkMs?: number;
|
||||||
|
relayBenchmarkMs?: number;
|
||||||
|
sourceDeleteMs?: number;
|
||||||
|
totalMs?: number;
|
||||||
|
transferBytes?: number;
|
||||||
|
endToEndMbPerSec?: number;
|
||||||
|
hops?: TransferHopMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(start: number): number {
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTransferMbPerSec(
|
||||||
|
bytes: number,
|
||||||
|
ms: number,
|
||||||
|
): number | undefined {
|
||||||
|
if (ms <= 0 || bytes <= 0) return undefined;
|
||||||
|
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HopWallClock {
|
||||||
|
firstAt: number | null;
|
||||||
|
lastAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHopWallClock(): HopWallClock {
|
||||||
|
return { firstAt: null, lastAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteHopStart(
|
||||||
|
clock: HopWallClock,
|
||||||
|
t: number = performance.now(),
|
||||||
|
): void {
|
||||||
|
if (clock.firstAt === null) clock.firstAt = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteHopEnd(
|
||||||
|
clock: HopWallClock,
|
||||||
|
t: number = performance.now(),
|
||||||
|
): void {
|
||||||
|
clock.lastAt = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hopSpanMs(clock: HopWallClock): number {
|
||||||
|
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||||
|
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||||
|
let pending = 0;
|
||||||
|
let lastFlush = 0;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (pending > 0) {
|
||||||
|
onProgress?.(pending);
|
||||||
|
pending = 0;
|
||||||
|
lastFlush = Date.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
add(bytes: number) {
|
||||||
|
pending += bytes;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelinedXferStats {
|
||||||
|
bytes: number;
|
||||||
|
sourceReadSpanMs: number;
|
||||||
|
destWriteSpanMs: number;
|
||||||
|
destWriteKind: "sftp" | "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyXferStats(): PipelinedXferStats {
|
||||||
|
return {
|
||||||
|
bytes: 0,
|
||||||
|
sourceReadSpanMs: 0,
|
||||||
|
destWriteSpanMs: 0,
|
||||||
|
destWriteKind: "sftp",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeXferStats(
|
||||||
|
target: PipelinedXferStats,
|
||||||
|
source: PipelinedXferStats,
|
||||||
|
): void {
|
||||||
|
target.bytes += source.bytes;
|
||||||
|
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||||
|
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||||
|
target.destWriteKind = source.destWriteKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTransferHopTimings(
|
||||||
|
stats: PipelinedXferStats,
|
||||||
|
transferMs: number,
|
||||||
|
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
||||||
|
const hops: TransferHopMetrics[] = [];
|
||||||
|
|
||||||
|
const sourceRate = computeTransferMbPerSec(
|
||||||
|
stats.bytes,
|
||||||
|
stats.sourceReadSpanMs,
|
||||||
|
);
|
||||||
|
if (sourceRate !== undefined) {
|
||||||
|
hops.push({
|
||||||
|
id: "source_read",
|
||||||
|
bytes: stats.bytes,
|
||||||
|
spanMs: stats.sourceReadSpanMs,
|
||||||
|
mbPerSec: sourceRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const destHopId: TransferHopId =
|
||||||
|
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
||||||
|
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
||||||
|
if (destRate !== undefined) {
|
||||||
|
hops.push({
|
||||||
|
id: destHopId,
|
||||||
|
bytes: stats.bytes,
|
||||||
|
spanMs: stats.destWriteSpanMs,
|
||||||
|
mbPerSec: destRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
transferBytes: stats.bytes,
|
||||||
|
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
||||||
|
hops,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Client as SSHClient } from "ssh2";
|
|||||||
import { fileLogger } from "../utils/logger.js";
|
import { fileLogger } from "../utils/logger.js";
|
||||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||||
|
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||||
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||||
@@ -46,6 +48,17 @@ async function resolveJumpHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class JumpHostChainError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly hopIndex: number,
|
||||||
|
readonly totalHops: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "JumpHostChainError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createJumpHostChain(
|
export async function createJumpHostChain(
|
||||||
jumpHosts: Array<{ hostId: number }>,
|
jumpHosts: Array<{ hostId: number }>,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -76,7 +89,11 @@ export async function createJumpHostChain(
|
|||||||
totalHops,
|
totalHops,
|
||||||
});
|
});
|
||||||
clients.forEach((c) => c.end());
|
clients.forEach((c) => c.end());
|
||||||
return null;
|
throw new JumpHostChainError(
|
||||||
|
`Jump host ${i + 1} of ${totalHops} was not found`,
|
||||||
|
i,
|
||||||
|
totalHops,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +123,20 @@ export async function createJumpHostChain(
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
// eslint-disable-next-line no-async-promise-executor
|
// eslint-disable-next-line no-async-promise-executor
|
||||||
const connected = await new Promise<boolean>(async (resolve) => {
|
const connected = await new Promise<boolean>(async (resolve) => {
|
||||||
|
const readyTimeoutMs = 60000;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
lastError = new Error(
|
||||||
|
`Timed out waiting for jump host ${i + 1}/${totalHops} to authenticate`,
|
||||||
|
);
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}, 30000);
|
// ssh2 has no explicit cancel; ending the client stops it from
|
||||||
|
// firing "ready"/"error" after we've already resolved.
|
||||||
|
jumpClient.end();
|
||||||
|
}, readyTimeoutMs + 5000);
|
||||||
|
|
||||||
jumpClient.on("ready", () => {
|
jumpClient.on("ready", () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -119,6 +145,7 @@ export async function createJumpHostChain(
|
|||||||
|
|
||||||
jumpClient.on("error", (err) => {
|
jumpClient.on("error", (err) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
lastError = err;
|
||||||
fileLogger.error(
|
fileLogger.error(
|
||||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||||
err,
|
err,
|
||||||
@@ -145,7 +172,7 @@ export async function createJumpHostChain(
|
|||||||
port: jumpHostConfig.port || 22,
|
port: jumpHostConfig.port || 22,
|
||||||
username: jumpHostConfig.username,
|
username: jumpHostConfig.username,
|
||||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||||
readyTimeout: 60000,
|
readyTimeout: readyTimeoutMs,
|
||||||
hostVerifier: jumpHostVerifier,
|
hostVerifier: jumpHostVerifier,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
@@ -190,11 +217,19 @@ export async function createJumpHostChain(
|
|||||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||||
connectConfig.password = jumpHostConfig.password;
|
connectConfig.password = jumpHostConfig.password;
|
||||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||||
const cleanKey = jumpHostConfig.key
|
try {
|
||||||
.trim()
|
connectConfig.privateKey = preparePrivateKeyForSSH2(
|
||||||
.replace(/\r\n/g, "\n")
|
jumpHostConfig.key,
|
||||||
.replace(/\r/g, "\n");
|
jumpHostConfig.keyPassword,
|
||||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
);
|
||||||
|
} catch (keyError) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
lastError = new Error(
|
||||||
|
`Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`,
|
||||||
|
);
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (jumpHostConfig.keyPassword) {
|
if (jumpHostConfig.keyPassword) {
|
||||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||||
}
|
}
|
||||||
@@ -237,6 +272,7 @@ export async function createJumpHostChain(
|
|||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
lastError = err;
|
||||||
resolve(false);
|
resolve(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -254,7 +290,14 @@ export async function createJumpHostChain(
|
|||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
clients.forEach((c) => c.end());
|
clients.forEach((c) => c.end());
|
||||||
return null;
|
throw new JumpHostChainError(
|
||||||
|
getErrorMessage(
|
||||||
|
lastError,
|
||||||
|
`Jump host ${i + 1} of ${totalHops} failed to connect`,
|
||||||
|
),
|
||||||
|
i,
|
||||||
|
totalHops,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentClient = jumpClient;
|
currentClient = jumpClient;
|
||||||
@@ -262,6 +305,7 @@ export async function createJumpHostChain(
|
|||||||
|
|
||||||
return currentClient;
|
return currentClient;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof JumpHostChainError) throw error;
|
||||||
fileLogger.error("Failed to create jump host chain", error, {
|
fileLogger.error("Failed to create jump host chain", error, {
|
||||||
operation: "jump_host_chain",
|
operation: "jump_host_chain",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -576,16 +576,32 @@ class PollingManager {
|
|||||||
} else {
|
} else {
|
||||||
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
|
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
|
||||||
}
|
}
|
||||||
|
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||||
|
let authenticated: boolean | undefined;
|
||||||
|
if (
|
||||||
|
isOnline &&
|
||||||
|
supportsMetrics(refreshedHost) &&
|
||||||
|
!config?.statsConfig.metricsEnabled
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await withSshConnection(refreshedHost, async () => undefined);
|
||||||
|
authenticated = true;
|
||||||
|
} catch {
|
||||||
|
authenticated = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const statusEntry: StatusEntry = {
|
const statusEntry: StatusEntry = {
|
||||||
status: statusAfterReachabilityCheck(
|
status:
|
||||||
|
authenticated === undefined
|
||||||
|
? statusAfterReachabilityCheck(
|
||||||
isOnline,
|
isOnline,
|
||||||
this.statusStore.get(refreshedHost.id)?.status,
|
this.statusStore.get(refreshedHost.id)?.status,
|
||||||
),
|
)
|
||||||
|
: statusAfterAuthentication(authenticated),
|
||||||
lastChecked: new Date().toISOString(),
|
lastChecked: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||||
if (isOnline && this.activeViewers.has(refreshedHost.id)) {
|
if (isOnline && this.activeViewers.has(refreshedHost.id)) {
|
||||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
|
||||||
if (config?.statsConfig.metricsEnabled) {
|
if (config?.statsConfig.metricsEnabled) {
|
||||||
this.scheduleInitialMetricsPoll(config.host, config.viewerUserId);
|
this.scheduleInitialMetricsPoll(config.host, config.viewerUserId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SerialPort } from "serialport";
|
|||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { DataCrypto } from "../utils/data-crypto.js";
|
import { DataCrypto } from "../utils/data-crypto.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import { parseWsMessage } from "../utils/ws-message.js";
|
||||||
|
|
||||||
interface SerialConnectData {
|
interface SerialConnectData {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -13,11 +14,6 @@ interface SerialConnectData {
|
|||||||
parity?: "none" | "even" | "odd";
|
parity?: "none" | "even" | "odd";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketMessage {
|
|
||||||
type: string;
|
|
||||||
data?: SerialConnectData | string | unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
const wss = new WebSocketServer({ port: 30011 });
|
const wss = new WebSocketServer({ port: 30011 });
|
||||||
@@ -93,15 +89,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ws.on("message", async (raw: RawData) => {
|
ws.on("message", async (raw: RawData) => {
|
||||||
let parsed: WebSocketMessage;
|
let type: string;
|
||||||
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw.toString()) as WebSocketMessage;
|
({ type, data } = parseWsMessage(raw));
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, data } = parsed;
|
try {
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "list_ports": {
|
case "list_ports": {
|
||||||
try {
|
try {
|
||||||
@@ -197,6 +193,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Error handling serial WebSocket message", err, {
|
||||||
|
operation: "serial_message_handler_error",
|
||||||
|
userId,
|
||||||
|
messageType: type,
|
||||||
|
});
|
||||||
|
send({ type: "error", data: "Failed to process message" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import dgram from "dgram";
|
import dgram from "dgram";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
import ssh2Pkg, {
|
import ssh2Pkg, {
|
||||||
|
type BaseAgent as BaseAgentType,
|
||||||
|
type GetStreamCallback,
|
||||||
type IdentityCallback,
|
type IdentityCallback,
|
||||||
|
type KnownPublicKeys,
|
||||||
type ParsedKey,
|
type ParsedKey,
|
||||||
type SignCallback,
|
type SignCallback,
|
||||||
type SigningRequestOptions,
|
type SigningRequestOptions,
|
||||||
} from "ssh2";
|
} from "ssh2";
|
||||||
|
|
||||||
const { BaseAgent } = ssh2Pkg;
|
type KnownPublicKey = KnownPublicKeys[number];
|
||||||
|
|
||||||
|
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||||
|
|
||||||
type Sleep = (ms: number) => Promise<void>;
|
type Sleep = (ms: number) => Promise<void>;
|
||||||
@@ -34,6 +39,23 @@ export class MemoryAgent extends BaseAgent {
|
|||||||
cb(null, [this.key]);
|
cb(null, [this.key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStream(cb: GetStreamCallback): void {
|
||||||
|
const protocol = new AgentProtocol(false);
|
||||||
|
|
||||||
|
protocol.on("identities", (request) => {
|
||||||
|
protocol.getIdentitiesReply(request, [this.key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
protocol.on("sign", (request, publicKey, data, options) => {
|
||||||
|
this.sign(publicKey, data, options, (error, signature) => {
|
||||||
|
if (error || !signature) return protocol.failureReply(request);
|
||||||
|
protocol.signReply(request, signature);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cb(null, protocol);
|
||||||
|
}
|
||||||
|
|
||||||
sign(
|
sign(
|
||||||
_pubKey: ParsedKey | Buffer | string,
|
_pubKey: ParsedKey | Buffer | string,
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
@@ -85,6 +107,84 @@ export async function resolveAgentSocket(
|
|||||||
return { socketPath: resolved };
|
return { socketPath: resolved };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an agent so only identities matching a specific public key are
|
||||||
|
* offered to the server, mirroring ssh_config's IdentityFile + IdentitiesOnly
|
||||||
|
* for agent auth. Prevents exhausting the server's MaxAuthTries when the
|
||||||
|
* agent holds many keys.
|
||||||
|
*/
|
||||||
|
export class FilteredAgent extends BaseAgent {
|
||||||
|
private inner: BaseAgentType;
|
||||||
|
private publicKeyBlob: Buffer;
|
||||||
|
|
||||||
|
constructor(inner: BaseAgentType, publicKeyBlob: Buffer) {
|
||||||
|
super();
|
||||||
|
this.inner = inner;
|
||||||
|
this.publicKeyBlob = publicKeyBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(key: KnownPublicKey): boolean {
|
||||||
|
try {
|
||||||
|
const blob =
|
||||||
|
typeof key === "string"
|
||||||
|
? Buffer.from(key)
|
||||||
|
: Buffer.isBuffer(key)
|
||||||
|
? key
|
||||||
|
: "getPublicSSH" in key
|
||||||
|
? key.getPublicSSH()
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
Buffer.isBuffer(blob) && Buffer.compare(blob, this.publicKeyBlob) === 0
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdentities(cb: IdentityCallback): void {
|
||||||
|
this.inner.getIdentities((err, keys) => {
|
||||||
|
if (err || !keys) return cb(err, keys);
|
||||||
|
cb(
|
||||||
|
null,
|
||||||
|
keys.filter((key) => this.matches(key)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getStream(cb: GetStreamCallback): void {
|
||||||
|
if (typeof this.inner.getStream !== "function") {
|
||||||
|
return cb(new Error("Agent does not support forwarding."));
|
||||||
|
}
|
||||||
|
this.inner.getStream(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(
|
||||||
|
pubKey: ParsedKey | Buffer | string,
|
||||||
|
data: Buffer,
|
||||||
|
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||||
|
cb?: SignCallback,
|
||||||
|
): void {
|
||||||
|
this.inner.sign(
|
||||||
|
pubKey,
|
||||||
|
data,
|
||||||
|
optionsOrCb as SigningRequestOptions,
|
||||||
|
cb as SignCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentIdentityBlob(agentIdentity: string): Buffer | null {
|
||||||
|
const { utils } = ssh2Pkg;
|
||||||
|
const parsed = utils.parseKey(agentIdentity.trim());
|
||||||
|
if (parsed instanceof Error || !parsed) return null;
|
||||||
|
const key = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||||
|
try {
|
||||||
|
return key.getPublicSSH();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyAgentAuth(
|
export async function applyAgentAuth(
|
||||||
connectConfig: Record<string, unknown>,
|
connectConfig: Record<string, unknown>,
|
||||||
terminalConfig: Record<string, unknown> | undefined,
|
terminalConfig: Record<string, unknown> | undefined,
|
||||||
@@ -93,7 +193,21 @@ export async function applyAgentAuth(
|
|||||||
if ("error" in result) return result;
|
if ("error" in result) return result;
|
||||||
|
|
||||||
const { createAgent } = ssh2Pkg;
|
const { createAgent } = ssh2Pkg;
|
||||||
connectConfig.agent = createAgent(result.socketPath);
|
const agent = createAgent(result.socketPath);
|
||||||
|
|
||||||
|
const agentIdentity = (
|
||||||
|
terminalConfig?.agentIdentity as string | undefined
|
||||||
|
)?.trim();
|
||||||
|
if (agentIdentity) {
|
||||||
|
const publicKeyBlob = parseAgentIdentityBlob(agentIdentity);
|
||||||
|
if (!publicKeyBlob) {
|
||||||
|
return { error: "Invalid public key provided for agent identity." };
|
||||||
|
}
|
||||||
|
connectConfig.agent = new FilteredAgent(agent, publicKeyBlob);
|
||||||
|
} else {
|
||||||
|
connectConfig.agent = agent;
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
|||||||
"This host does not exist on the sync server, so the connection was refused. " +
|
"This host does not exist on the sync server, so the connection was refused. " +
|
||||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||||
|
|
||||||
|
export function resolveServerJumpHosts(
|
||||||
|
clientJumpHosts: Array<{ hostId: number }> | undefined,
|
||||||
|
serverJumpHosts: Array<{ hostId: number }> | undefined,
|
||||||
|
hostSyncId?: string | null,
|
||||||
|
): Array<{ hostId: number }> | undefined {
|
||||||
|
if (hostSyncId) return serverJumpHosts ?? [];
|
||||||
|
return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown where a mismatch is reported by rejecting rather than by messaging
|
* Thrown where a mismatch is reported by rejecting rather than by messaging
|
||||||
* the socket. Callers whose host-resolution is wrapped in a "failed to resolve
|
* the socket. Callers whose host-resolution is wrapped in a "failed to resolve
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import {
|
||||||
|
parseWsMessage,
|
||||||
|
asObject,
|
||||||
|
asString,
|
||||||
|
toTerminalDimension,
|
||||||
|
} from "../../utils/ws-message.js";
|
||||||
import { StringDecoder } from "string_decoder";
|
import { StringDecoder } from "string_decoder";
|
||||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||||
import ssh2Pkg, {
|
import ssh2Pkg, {
|
||||||
@@ -21,7 +27,7 @@ import {
|
|||||||
import { SSHAuthManager } from "../auth-manager.js";
|
import { SSHAuthManager } from "../auth-manager.js";
|
||||||
import type { ProxyNode } from "../../../types/index.js";
|
import type { ProxyNode } from "../../../types/index.js";
|
||||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js";
|
||||||
import {
|
import {
|
||||||
parseTailscaleCheckBanner,
|
parseTailscaleCheckBanner,
|
||||||
isTailscaleCheckCompleteBanner,
|
isTailscaleCheckCompleteBanner,
|
||||||
@@ -42,7 +48,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
MemoryAgent,
|
MemoryAgent,
|
||||||
performPortKnocking,
|
performPortKnocking,
|
||||||
resolveAgentSocket,
|
applyAgentAuth,
|
||||||
} from "../terminal-auth-helpers.js";
|
} from "../terminal-auth-helpers.js";
|
||||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
|
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
|
||||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||||
@@ -54,6 +60,7 @@ import {
|
|||||||
hostAddressMismatch,
|
hostAddressMismatch,
|
||||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||||
|
resolveServerJumpHosts,
|
||||||
} from "./host-identity.js";
|
} from "./host-identity.js";
|
||||||
|
|
||||||
interface ConnectToHostData {
|
interface ConnectToHostData {
|
||||||
@@ -112,13 +119,6 @@ interface TOTPResponseData {
|
|||||||
code?: string;
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WebSocketMessage {
|
|
||||||
type: string;
|
|
||||||
data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown;
|
|
||||||
code?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
// Tailscale holds a check-mode connection open for up to 30 minutes while the
|
// Tailscale holds a check-mode connection open for up to 30 minutes while the
|
||||||
@@ -236,13 +236,13 @@ async function handleShareTokenConnection(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (msg: RawData) => {
|
ws.on("message", (msg: RawData) => {
|
||||||
let parsed: WebSocketMessage;
|
let type: string;
|
||||||
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
({ type, data } = parseWsMessage(msg));
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { type, data } = parsed;
|
|
||||||
|
|
||||||
const liveSession = sessionManager.getSession(currentSessionId);
|
const liveSession = sessionManager.getSession(currentSessionId);
|
||||||
const participant = liveSession
|
const participant = liveSession
|
||||||
@@ -254,7 +254,8 @@ async function handleShareTokenConnection(
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "input": {
|
case "input": {
|
||||||
const inputData = data as string;
|
if (typeof data !== "string") break;
|
||||||
|
const inputData = data;
|
||||||
sessionManager.bufferInput(currentSessionId, inputData);
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
const inputStream = liveSession?.sshStream;
|
const inputStream = liveSession?.sshStream;
|
||||||
if (inputStream) {
|
if (inputStream) {
|
||||||
@@ -476,21 +477,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: WebSocketMessage;
|
let type: string;
|
||||||
|
let data: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
({ type, data } = parseWsMessage(msg));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sshLogger.error("Invalid JSON received", e, {
|
sshLogger.warn("Rejected malformed WebSocket message", {
|
||||||
operation: "websocket_message_invalid_json",
|
operation: "websocket_message_invalid",
|
||||||
userId,
|
userId,
|
||||||
messageLength: msg.toString().length,
|
error: getErrorMessage(e),
|
||||||
});
|
});
|
||||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, data } = parsed;
|
|
||||||
|
|
||||||
// Server-side gate: non-owner participants (read-only or read-write
|
// Server-side gate: non-owner participants (read-only or read-write
|
||||||
// guests/joiners) may only send input/ping/disconnect - everything else
|
// guests/joiners) may only send input/ping/disconnect - everything else
|
||||||
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
||||||
@@ -506,12 +506,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "connectToHost": {
|
case "connectToHost": {
|
||||||
const connectData = data as ConnectToHostData;
|
const connectData = data as ConnectToHostData;
|
||||||
if (connectData.hostConfig) {
|
if (!connectData?.hostConfig) {
|
||||||
connectData.hostConfig.userId = userId;
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Missing host configuration",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
connectData.hostConfig.userId = userId;
|
||||||
handleConnectToHost(connectData).catch((error) => {
|
handleConnectToHost(connectData).catch((error) => {
|
||||||
const errMsg = getErrorMessage(error);
|
const errMsg = getErrorMessage(error);
|
||||||
if (
|
if (
|
||||||
@@ -584,18 +592,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (buffered) {
|
if (buffered) {
|
||||||
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||||
}
|
}
|
||||||
|
const attachCols = toTerminalDimension(attachData.cols);
|
||||||
|
const attachRows = toTerminalDimension(attachData.rows);
|
||||||
if (
|
if (
|
||||||
attachData.cols !== session.cols ||
|
attachCols &&
|
||||||
attachData.rows !== session.rows
|
attachRows &&
|
||||||
|
(attachCols !== session.cols || attachRows !== session.rows)
|
||||||
) {
|
) {
|
||||||
session.sshStream?.setWindow(
|
session.sshStream?.setWindow(
|
||||||
attachData.rows,
|
attachRows,
|
||||||
attachData.cols,
|
attachCols,
|
||||||
attachData.rows,
|
attachRows,
|
||||||
attachData.cols,
|
attachCols,
|
||||||
);
|
);
|
||||||
session.cols = attachData.cols;
|
session.cols = attachCols;
|
||||||
session.rows = attachData.rows;
|
session.rows = attachRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -707,7 +718,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "open_file_in_editor": {
|
case "open_file_in_editor": {
|
||||||
const { path: requestedPath } = data as { path: string };
|
const requestedPath = asString(asObject(data).path);
|
||||||
const activeConn =
|
const activeConn =
|
||||||
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
||||||
if (!activeConn || !requestedPath) {
|
if (!activeConn || !requestedPath) {
|
||||||
@@ -754,7 +765,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "input": {
|
case "input": {
|
||||||
const inputData = data as string;
|
if (typeof data !== "string") break;
|
||||||
|
const inputData = data;
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionManager.bufferInput(currentSessionId, inputData);
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
}
|
}
|
||||||
@@ -906,7 +918,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Password authentication state lost. Please reconnect.",
|
message:
|
||||||
|
"Password authentication state lost. Please reconnect.",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -936,6 +949,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!credentialsData?.hostConfig) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Missing host configuration",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (credentialsData.password) {
|
if (credentialsData.password) {
|
||||||
credentialsData.hostConfig.password = credentialsData.password;
|
credentialsData.hostConfig.password = credentialsData.password;
|
||||||
credentialsData.hostConfig.authType = "password";
|
credentialsData.hostConfig.authType = "password";
|
||||||
@@ -944,10 +967,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
).userProvidedPassword = true;
|
).userProvidedPassword = true;
|
||||||
} else if (credentialsData.sshKey) {
|
} else if (credentialsData.sshKey) {
|
||||||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
credentialsData.hostConfig.keyPassword =
|
||||||
|
credentialsData.keyPassword;
|
||||||
credentialsData.hostConfig.authType = "key";
|
credentialsData.hostConfig.authType = "key";
|
||||||
} else if (credentialsData.keyPassword) {
|
} else if (credentialsData.keyPassword) {
|
||||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
credentialsData.hostConfig.keyPassword =
|
||||||
|
credentialsData.keyPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAwaitingAuthCredentials = false;
|
isAwaitingAuthCredentials = false;
|
||||||
@@ -990,7 +1015,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Failed to connect with provided credentials: " + errMsg,
|
message:
|
||||||
|
"Failed to connect with provided credentials: " + errMsg,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1325,6 +1351,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
messageType: type,
|
messageType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// A malformed payload must never escape into the process-level
|
||||||
|
// unhandledRejection handler, which exits the server.
|
||||||
|
sshLogger.error("Error handling WebSocket message", error, {
|
||||||
|
operation: "websocket_message_handler_error",
|
||||||
|
userId,
|
||||||
|
messageType: type,
|
||||||
|
});
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to process message",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleConnectToHost(data: ConnectToHostData) {
|
async function handleConnectToHost(data: ConnectToHostData) {
|
||||||
@@ -1542,16 +1585,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedHostData) {
|
if (resolvedHostData) {
|
||||||
if (
|
const resolvedJumpHosts = resolveServerJumpHosts(
|
||||||
(!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) &&
|
hostConfig.jumpHosts,
|
||||||
resolvedHostData.jumpHosts &&
|
resolvedHostData.jumpHosts,
|
||||||
resolvedHostData.jumpHosts.length > 0
|
hostSyncId,
|
||||||
) {
|
);
|
||||||
hostConfig.jumpHosts = resolvedHostData.jumpHosts;
|
if (resolvedJumpHosts !== hostConfig.jumpHosts) {
|
||||||
|
hostConfig.jumpHosts = resolvedJumpHosts;
|
||||||
sendLog(
|
sendLog(
|
||||||
"jump",
|
"jump",
|
||||||
"info",
|
"info",
|
||||||
`Loaded ${resolvedHostData.jumpHosts.length} jump host(s) from server-side host data`,
|
`Loaded ${resolvedJumpHosts?.length ?? 0} jump host(s) from server-side host data`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3019,15 +3063,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
} else if (resolvedCredentials.authType === "agent") {
|
} else if (resolvedCredentials.authType === "agent") {
|
||||||
sendLog("auth", "info", "Using SSH agent authentication");
|
sendLog("auth", "info", "Using SSH agent authentication");
|
||||||
const result = await resolveAgentSocket(
|
const result = await applyAgentAuth(
|
||||||
|
connectConfig,
|
||||||
hostConfig.terminalConfig as Record<string, unknown> | undefined,
|
hostConfig.terminalConfig as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
ws.send(JSON.stringify({ type: "error", message: result.error }));
|
ws.send(JSON.stringify({ type: "error", message: result.error }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { createAgent } = ssh2Pkg;
|
|
||||||
connectConfig.agent = createAgent(result.socketPath);
|
|
||||||
sendLog(
|
sendLog(
|
||||||
"auth",
|
"auth",
|
||||||
"info",
|
"info",
|
||||||
@@ -3245,7 +3288,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Failed to connect through jump hosts",
|
message:
|
||||||
|
error instanceof JumpHostChainError
|
||||||
|
? `Failed to connect through jump hosts: ${error.message}`
|
||||||
|
: "Failed to connect through jump hosts",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
@@ -3314,19 +3360,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleResize(data: ResizeData) {
|
function handleResize(data: ResizeData) {
|
||||||
|
const cols = toTerminalDimension(data?.cols);
|
||||||
|
const rows = toTerminalDimension(data?.rows);
|
||||||
|
if (!cols || !rows) return;
|
||||||
|
|
||||||
const resizeStream =
|
const resizeStream =
|
||||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||||
if (resizeStream && resizeStream.setWindow) {
|
if (resizeStream && resizeStream.setWindow) {
|
||||||
resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
resizeStream.setWindow(rows, cols, rows, cols);
|
||||||
const session = sessionManager.getSession(currentSessionId);
|
const session = sessionManager.getSession(currentSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.cols = data.cols;
|
session.cols = cols;
|
||||||
session.rows = data.rows;
|
session.rows = rows;
|
||||||
sessionManager.bufferResize(session.id, data.cols, data.rows);
|
sessionManager.bufferResize(session.id, cols, rows);
|
||||||
}
|
}
|
||||||
ws.send(
|
ws.send(JSON.stringify({ type: "resized", cols, rows }));
|
||||||
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,8 +177,8 @@ export function buildPaneMetrics(
|
|||||||
const treePids: number[] = [];
|
const treePids: number[] = [];
|
||||||
const queue = [pane.pid];
|
const queue = [pane.pid];
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
while (queue.length > 0) {
|
for (let cursor = 0; cursor < queue.length; cursor++) {
|
||||||
const pid = queue.shift()!;
|
const pid = queue[cursor];
|
||||||
if (seen.has(pid)) continue;
|
if (seen.has(pid)) continue;
|
||||||
seen.add(pid);
|
seen.add(pid);
|
||||||
if (byPid.has(pid)) treePids.push(pid);
|
if (byPid.has(pid)) treePids.push(pid);
|
||||||
@@ -225,9 +225,19 @@ export function attachPanesToWindows(
|
|||||||
windows: Map<string, TmuxWindow[]>,
|
windows: Map<string, TmuxWindow[]>,
|
||||||
panes: RawPane[],
|
panes: RawPane[],
|
||||||
): void {
|
): void {
|
||||||
|
const windowsBySessionAndIndex = new Map<string, Map<number, TmuxWindow>>();
|
||||||
|
for (const [sessionName, sessionWindows] of windows) {
|
||||||
|
const byIndex = new Map<number, TmuxWindow>();
|
||||||
|
for (const window of sessionWindows) {
|
||||||
|
if (!byIndex.has(window.index)) byIndex.set(window.index, window);
|
||||||
|
}
|
||||||
|
windowsBySessionAndIndex.set(sessionName, byIndex);
|
||||||
|
}
|
||||||
|
|
||||||
for (const pane of panes) {
|
for (const pane of panes) {
|
||||||
const sessionWindows = windows.get(pane.sessionName) || [];
|
const window = windowsBySessionAndIndex
|
||||||
const window = sessionWindows.find((w) => w.index === pane.windowIndex);
|
.get(pane.sessionName)
|
||||||
|
?.get(pane.windowIndex);
|
||||||
if (window) {
|
if (window) {
|
||||||
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
||||||
window.panes.push(paneFields);
|
window.panes.push(paneFields);
|
||||||
|
|||||||
@@ -384,18 +384,36 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A single bad request must not take the server down. Exit only on errors
|
||||||
|
// that leave the process genuinely unusable; log and keep serving
|
||||||
|
// otherwise, since these are almost always scoped to one connection.
|
||||||
|
const isFatalError = (error: unknown): boolean => {
|
||||||
|
const code = (error as NodeJS.ErrnoException)?.code;
|
||||||
|
if (code === "ERR_WORKER_OUT_OF_MEMORY") return true;
|
||||||
|
if (error instanceof RangeError) {
|
||||||
|
return /call stack|heap out of memory/i.test(error.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
systemLogger.error("Uncaught exception occurred", error, {
|
systemLogger.error("Uncaught exception occurred", error, {
|
||||||
operation: "error_handling",
|
operation: "error_handling",
|
||||||
|
fatal: isFatalError(error),
|
||||||
});
|
});
|
||||||
|
if (isFatalError(error)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
process.on("unhandledRejection", (reason) => {
|
||||||
systemLogger.error("Unhandled promise rejection", reason, {
|
systemLogger.error("Unhandled promise rejection", reason, {
|
||||||
operation: "error_handling",
|
operation: "error_handling",
|
||||||
|
fatal: isFatalError(reason),
|
||||||
});
|
});
|
||||||
|
if (isFatalError(reason)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
systemLogger.error("Failed to initialize backend services", error, {
|
systemLogger.error("Failed to initialize backend services", error, {
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const repository = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveHostById = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||||
|
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../database/repositories/factory.js", () => ({
|
vi.mock("../../database/repositories/factory.js", () => ({
|
||||||
createCurrentAutomationRepository: () => repository,
|
createCurrentAutomationRepository: () => repository,
|
||||||
}));
|
}));
|
||||||
@@ -93,12 +99,41 @@ beforeEach(() => {
|
|||||||
nextRunId = 1;
|
nextRunId = 1;
|
||||||
nextStepRowId = 1;
|
nextStepRowId = 1;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
resolveHostById.mockResolvedValue(null);
|
||||||
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
||||||
// The singleton carries in-flight state between tests.
|
// The singleton carries in-flight state between tests.
|
||||||
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AutomationEngine.run", () => {
|
describe("AutomationEngine.run", () => {
|
||||||
|
it("adds the trigger host name to the template context", async () => {
|
||||||
|
defineAutomation([step({ id: "notify", type: "notify" })]);
|
||||||
|
resolveHostById.mockResolvedValue({
|
||||||
|
name: "Proxmox Node",
|
||||||
|
ip: "10.0.0.11",
|
||||||
|
username: "root",
|
||||||
|
port: 22,
|
||||||
|
});
|
||||||
|
|
||||||
|
await AutomationEngine.getInstance().run({
|
||||||
|
automationId: 1,
|
||||||
|
triggerType: "metric_threshold",
|
||||||
|
triggerHostId: 11,
|
||||||
|
triggerContext: { hostId: 11, value: 97 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = executeStep.mock.calls[0][1] as {
|
||||||
|
template: {
|
||||||
|
host: { id: number; name: string };
|
||||||
|
trigger: { hostName: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(context.template.host).toMatchObject({
|
||||||
|
id: 11,
|
||||||
|
name: "Proxmox Node",
|
||||||
|
});
|
||||||
|
expect(context.template.trigger.hostName).toBe("Proxmox Node");
|
||||||
|
});
|
||||||
it("runs steps in order and records each one", async () => {
|
it("runs steps in order and records each one", async () => {
|
||||||
defineAutomation([
|
defineAutomation([
|
||||||
step({ id: "a", type: "run_command" }),
|
step({ id: "a", type: "run_command" }),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const automationFetch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../automations/http.js", () => ({
|
||||||
|
automationFetch: (...args: unknown[]) => automationFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendAutomationNotification } =
|
||||||
|
await import("../../automations/notify.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
automationFetch.mockReset();
|
||||||
|
automationFetch.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendAutomationNotification", () => {
|
||||||
|
it("keeps alert-compatible host and rule fields in webhook payloads", async () => {
|
||||||
|
await sendAutomationNotification(
|
||||||
|
{ id: 1, type: "webhook", config: '{"url":"https://example.com"}' },
|
||||||
|
{
|
||||||
|
title: "CPU warning",
|
||||||
|
body: "cpu.percent is at 97",
|
||||||
|
severity: "warning",
|
||||||
|
context: {
|
||||||
|
host: { id: 11, name: "Proxmox Node" },
|
||||||
|
trigger: { value: 97, threshold: 90 },
|
||||||
|
run: { automationId: 42 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = automationFetch.mock.calls[0][1] as RequestInit;
|
||||||
|
expect(JSON.parse(options.body as string)).toMatchObject({
|
||||||
|
hostName: "Proxmox Node",
|
||||||
|
hostId: 11,
|
||||||
|
ruleName: "CPU warning",
|
||||||
|
ruleId: 42,
|
||||||
|
value: 97,
|
||||||
|
threshold: 90,
|
||||||
|
message: "cpu.percent is at 97",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { TestSqliteDatabase } from "./test-support.js";
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
import { HostRepository } from "../../../database/repositories/host-repository.js";
|
import { HostRepository } from "../../../database/repositories/host-repository.js";
|
||||||
|
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||||
|
|
||||||
describe("HostRepository.reorderForUser", () => {
|
describe("HostRepository.reorderForUser", () => {
|
||||||
let adapter: TestSqliteDatabase | null = null;
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
@@ -72,3 +73,100 @@ describe("HostRepository.reorderForUser", () => {
|
|||||||
await expect(repo.reorderForUser("user-1", [])).resolves.toBe(0);
|
await expect(repo.reorderForUser("user-1", [])).resolves.toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HostRepository Proxmox sync inserts", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await adapter?.close();
|
||||||
|
adapter = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a discovered guest using the scheduled-sync payload", async () => {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
await adapter.exec(`
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('user-1', 'alice', 'hash');
|
||||||
|
INSERT INTO ssh_credentials (id, user_id, name, auth_type, username)
|
||||||
|
VALUES (7, 'user-1', 'guest key', 'key', 'alice');
|
||||||
|
`);
|
||||||
|
const repository = new HostRepository(context);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
|
||||||
|
Buffer.alloc(32, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await repository.createEncryptedForUser("user-1", {
|
||||||
|
userId: "user-1",
|
||||||
|
name: "guest",
|
||||||
|
ip: "10.0.0.8",
|
||||||
|
port: 22,
|
||||||
|
username: "",
|
||||||
|
connectionType: "ssh",
|
||||||
|
folder: "Proxmox",
|
||||||
|
tags: "proxmox,qemu,node-1,vm-100",
|
||||||
|
proxmoxConfig: JSON.stringify({
|
||||||
|
source: {
|
||||||
|
source: "proxmox",
|
||||||
|
sourceHostId: 1,
|
||||||
|
node: "node-1",
|
||||||
|
vmid: 100,
|
||||||
|
type: "qemu",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updatedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
pin: false,
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 7,
|
||||||
|
overrideCredentialUsername: 0,
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableFileManager: true,
|
||||||
|
enableTunnel: true,
|
||||||
|
enableDocker: false,
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: false,
|
||||||
|
rdpUser: null,
|
||||||
|
rdpPassword: null,
|
||||||
|
rdpDomain: null,
|
||||||
|
rdpSecurity: null,
|
||||||
|
rdpIgnoreCert: 0,
|
||||||
|
rdpPort: null,
|
||||||
|
vncUser: null,
|
||||||
|
vncPassword: null,
|
||||||
|
vncPort: null,
|
||||||
|
telnetUser: null,
|
||||||
|
telnetPassword: null,
|
||||||
|
telnetPort: null,
|
||||||
|
defaultPath: "/",
|
||||||
|
tunnelConnections: "[]",
|
||||||
|
jumpHosts: null,
|
||||||
|
quickActions: null,
|
||||||
|
statsConfig: null,
|
||||||
|
dockerConfig: null,
|
||||||
|
terminalConfig: null,
|
||||||
|
forceKeyboardInteractive: "false",
|
||||||
|
useSocks5: 0,
|
||||||
|
socks5Host: null,
|
||||||
|
socks5Port: null,
|
||||||
|
socks5Username: null,
|
||||||
|
socks5Password: null,
|
||||||
|
socks5ProxyChain: null,
|
||||||
|
portKnockSequence: null,
|
||||||
|
showTerminalInSidebar: 0,
|
||||||
|
showFileManagerInSidebar: 0,
|
||||||
|
showTunnelInSidebar: 0,
|
||||||
|
showDockerInSidebar: 0,
|
||||||
|
showServerStatsInSidebar: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.username).toBe("");
|
||||||
|
expect(created.credentialId).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const factory = vi.hoisted(() => ({
|
||||||
|
getCurrentRepositorySqlite: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => factory);
|
||||||
|
|
||||||
|
import {
|
||||||
|
withCurrentSqliteForeignKeysDisabled,
|
||||||
|
withSqliteForeignKeysDisabled,
|
||||||
|
} from "../../../database/repositories/sqlite-foreign-keys.js";
|
||||||
|
|
||||||
|
const previousDatabaseDialect = process.env.DATABASE_DIALECT;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousDatabaseDialect === undefined)
|
||||||
|
delete process.env.DATABASE_DIALECT;
|
||||||
|
else process.env.DATABASE_DIALECT = previousDatabaseDialect;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withSqliteForeignKeysDisabled", () => {
|
||||||
|
it("restores foreign keys after an import", async () => {
|
||||||
|
const sqlite = { exec: vi.fn() };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withSqliteForeignKeysDisabled(sqlite, async () => "imported"),
|
||||||
|
).resolves.toBe("imported");
|
||||||
|
expect(sqlite.exec.mock.calls).toEqual([
|
||||||
|
["PRAGMA foreign_keys = OFF"],
|
||||||
|
["PRAGMA foreign_keys = ON"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withCurrentSqliteForeignKeysDisabled", () => {
|
||||||
|
it.each(["postgres", "mysql"])(
|
||||||
|
"runs portable imports with constraints enabled on %s",
|
||||||
|
async (dialect) => {
|
||||||
|
process.env.DATABASE_DIALECT = dialect;
|
||||||
|
const operation = vi.fn().mockResolvedValue("imported");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withCurrentSqliteForeignKeysDisabled(operation),
|
||||||
|
).resolves.toBe("imported");
|
||||||
|
expect(operation).toHaveBeenCalledOnce();
|
||||||
|
expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ describe("resolveProxmoxImportAuth", () => {
|
|||||||
expect(resolveProxmoxImportAuth("key", 7)).toEqual({
|
expect(resolveProxmoxImportAuth("key", 7)).toEqual({
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId: 7,
|
credentialId: 7,
|
||||||
overrideCredentialUsername: 1,
|
overrideCredentialUsername: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ describe("resolveProxmoxImportAuth", () => {
|
|||||||
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
|
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId: 7,
|
credentialId: 7,
|
||||||
overrideCredentialUsername: 1,
|
overrideCredentialUsername: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ describe("resolveProxmoxImportAuth", () => {
|
|||||||
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId: 42,
|
credentialId: 42,
|
||||||
overrideCredentialUsername: 1,
|
overrideCredentialUsername: 0,
|
||||||
});
|
});
|
||||||
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
|
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
|
||||||
authType: "none",
|
authType: "none",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({ createAdminMiddleware: vi.fn() }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { isValidOidcIssuer } =
|
||||||
|
await import("../../../database/routes/sso-provider-routes.js");
|
||||||
|
|
||||||
|
describe("isValidOidcIssuer", () => {
|
||||||
|
it("rejects userinfo endpoints used as issuer URLs", () => {
|
||||||
|
expect(
|
||||||
|
isValidOidcIssuer("https://auth.example/application/o/userinfo/"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an Authentik application issuer", () => {
|
||||||
|
expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
resolveOidcMappedRoles,
|
resolveOidcMappedRoles,
|
||||||
verifyOIDCToken,
|
verifyOIDCToken,
|
||||||
describeFetchFailure,
|
describeFetchFailure,
|
||||||
|
isOIDCEnvOverrideEnabled,
|
||||||
} = await import("../../../database/routes/user-oidc-utils.js");
|
} = await import("../../../database/routes/user-oidc-utils.js");
|
||||||
|
|
||||||
const BACKCHANNEL_LOGOUT_EVENT =
|
const BACKCHANNEL_LOGOUT_EVENT =
|
||||||
@@ -281,6 +282,7 @@ describe("getOIDCConfigFromEnv", () => {
|
|||||||
"OIDC_SCOPES",
|
"OIDC_SCOPES",
|
||||||
"OIDC_ALLOWED_USERS",
|
"OIDC_ALLOWED_USERS",
|
||||||
"OIDC_ADMIN_GROUP",
|
"OIDC_ADMIN_GROUP",
|
||||||
|
"OIDC_ENV_OVERRIDE",
|
||||||
];
|
];
|
||||||
const saved: Record<string, string | undefined> = {};
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
@@ -334,6 +336,12 @@ describe("getOIDCConfigFromEnv", () => {
|
|||||||
expect(config?.identifier_path).toBe("email");
|
expect(config?.identifier_path).toBe("email");
|
||||||
expect(config?.scopes).toBe("openid");
|
expect(config?.scopes).toBe("openid");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("only enables database recovery override when explicitly requested", () => {
|
||||||
|
expect(isOIDCEnvOverrideEnabled()).toBe(false);
|
||||||
|
process.env.OIDC_ENV_OVERRIDE = "true";
|
||||||
|
expect(isOIDCEnvOverrideEnabled()).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("extractOidcGroups", () => {
|
describe("extractOidcGroups", () => {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { resolveLocalShell } =
|
||||||
|
require("../../../../electron/local-shell.cjs") as {
|
||||||
|
resolveLocalShell: (
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
requestedShell?: string,
|
||||||
|
env?: NodeJS.ProcessEnv,
|
||||||
|
) => { file: string; args: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveLocalShell", () => {
|
||||||
|
it("starts the default WSL distribution without PowerShell arguments", () => {
|
||||||
|
expect(resolveLocalShell("win32", "wsl", {})).toEqual({
|
||||||
|
file: "wsl.exe",
|
||||||
|
args: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps PowerShell as the default Windows shell", () => {
|
||||||
|
expect(resolveLocalShell("win32", "default", {})).toEqual({
|
||||||
|
file: "powershell.exe",
|
||||||
|
args: ["-NoLogo"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the configured shell on non-Windows platforms", () => {
|
||||||
|
expect(resolveLocalShell("linux", "wsl", { SHELL: "/bin/fish" })).toEqual({
|
||||||
|
file: "/bin/fish",
|
||||||
|
args: ["-l"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { generateKeyPairSync } from "crypto";
|
||||||
|
import ssh2Pkg, { type ParsedKey } from "ssh2";
|
||||||
|
|
||||||
const mockAccess = vi.fn();
|
const mockAccess = vi.fn();
|
||||||
|
|
||||||
@@ -6,7 +8,57 @@ vi.mock("fs/promises", () => ({
|
|||||||
access: mockAccess,
|
access: mockAccess,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { resolveAgentSocket } from "../../hosts/terminal-auth-helpers.js";
|
import {
|
||||||
|
MemoryAgent,
|
||||||
|
FilteredAgent,
|
||||||
|
resolveAgentSocket,
|
||||||
|
} from "../../hosts/terminal-auth-helpers.js";
|
||||||
|
|
||||||
|
describe("MemoryAgent", () => {
|
||||||
|
it("serves identities and signatures over the agent protocol", async () => {
|
||||||
|
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
|
const parsed = ssh2Pkg.utils.parseKey(
|
||||||
|
privateKey.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
);
|
||||||
|
expect(parsed).not.toBeInstanceOf(Error);
|
||||||
|
|
||||||
|
const agent = new MemoryAgent(parsed as ParsedKey);
|
||||||
|
const stream = await new Promise<NodeJS.ReadWriteStream>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
agent.getStream((error, result) => {
|
||||||
|
if (error || !result)
|
||||||
|
reject(error ?? new Error("Missing agent stream"));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const client = new ssh2Pkg.AgentProtocol(true);
|
||||||
|
client.pipe(stream).pipe(client);
|
||||||
|
|
||||||
|
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||||
|
client.getIdentities((error, keys) => {
|
||||||
|
if (error || !keys) reject(error ?? new Error("Missing identities"));
|
||||||
|
else resolve(keys);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(identities).toHaveLength(1);
|
||||||
|
expect(identities[0].getPublicSSH()).toEqual(
|
||||||
|
(parsed as ParsedKey).getPublicSSH(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = Buffer.from("forwarded-agent-test");
|
||||||
|
const signature = await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
client.sign(identities[0], data, (error, result) => {
|
||||||
|
if (error || !result) reject(error ?? new Error("Missing signature"));
|
||||||
|
else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
|
||||||
|
|
||||||
|
client.destroy();
|
||||||
|
stream.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveAgentSocket", () => {
|
describe("resolveAgentSocket", () => {
|
||||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||||
@@ -101,3 +153,78 @@ describe("resolveAgentSocket", () => {
|
|||||||
expect(mockAccess).not.toHaveBeenCalled();
|
expect(mockAccess).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FilteredAgent", () => {
|
||||||
|
it("only returns identities matching the configured public key", async () => {
|
||||||
|
const { privateKey: keyA } = generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
});
|
||||||
|
const { privateKey: keyB } = generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
});
|
||||||
|
const parsedA = ssh2Pkg.utils.parseKey(
|
||||||
|
keyA.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
) as ParsedKey;
|
||||||
|
const parsedB = ssh2Pkg.utils.parseKey(
|
||||||
|
keyB.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
) as ParsedKey;
|
||||||
|
|
||||||
|
const inner = {
|
||||||
|
getIdentities: (cb: (err: Error | null, keys: ParsedKey[]) => void) =>
|
||||||
|
cb(null, [parsedA, parsedB]),
|
||||||
|
getStream: vi.fn(),
|
||||||
|
sign: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = new FilteredAgent(
|
||||||
|
inner as unknown as ConstructorParameters<typeof FilteredAgent>[0],
|
||||||
|
parsedB.getPublicSSH(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||||
|
filtered.getIdentities((err, keys) => {
|
||||||
|
if (err || !keys) reject(err ?? new Error("Missing identities"));
|
||||||
|
else resolve(keys);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(identities).toHaveLength(1);
|
||||||
|
expect(identities[0].getPublicSSH()).toEqual(parsedB.getPublicSSH());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no identities when nothing matches", async () => {
|
||||||
|
const { privateKey: keyA } = generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
});
|
||||||
|
const { privateKey: keyB } = generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
});
|
||||||
|
const parsedA = ssh2Pkg.utils.parseKey(
|
||||||
|
keyA.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
) as ParsedKey;
|
||||||
|
const parsedB = ssh2Pkg.utils.parseKey(
|
||||||
|
keyB.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
) as ParsedKey;
|
||||||
|
|
||||||
|
const inner = {
|
||||||
|
getIdentities: (cb: (err: Error | null, keys: ParsedKey[]) => void) =>
|
||||||
|
cb(null, [parsedA]),
|
||||||
|
getStream: vi.fn(),
|
||||||
|
sign: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = new FilteredAgent(
|
||||||
|
inner as unknown as ConstructorParameters<typeof FilteredAgent>[0],
|
||||||
|
parsedB.getPublicSSH(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
|
||||||
|
filtered.getIdentities((err, keys) => {
|
||||||
|
if (err || !keys) reject(err ?? new Error("Missing identities"));
|
||||||
|
else resolve(keys);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(identities).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HostAddressMismatchError,
|
HostAddressMismatchError,
|
||||||
HostNotOnThisServerError,
|
HostNotOnThisServerError,
|
||||||
normalizeHostAddress,
|
normalizeHostAddress,
|
||||||
|
resolveServerJumpHosts,
|
||||||
} from "../../../hosts/terminal/host-identity.js";
|
} from "../../../hosts/terminal/host-identity.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +60,20 @@ describe("hostAddressMismatch", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveServerJumpHosts", () => {
|
||||||
|
it("uses server-side ids for a sync-delegated connection", () => {
|
||||||
|
expect(
|
||||||
|
resolveServerJumpHosts([{ hostId: 7 }], [{ hostId: 42 }], "host-sync-id"),
|
||||||
|
).toEqual([{ hostId: 42 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps client ids for a local id-based connection", () => {
|
||||||
|
expect(resolveServerJumpHosts([{ hostId: 7 }], [{ hostId: 42 }])).toEqual([
|
||||||
|
{ hostId: 7 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("HostAddressMismatchError", () => {
|
describe("HostAddressMismatchError", () => {
|
||||||
it("survives the catch blocks that swallow resolution failures", () => {
|
it("survives the catch blocks that swallow resolution failures", () => {
|
||||||
// SFTP host resolution sits inside "failed to resolve credentials, carry
|
// SFTP host resolution sits inside "failed to resolve credentials, carry
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
buildPaneMetrics,
|
buildPaneMetrics,
|
||||||
attachPanesToWindows,
|
attachPanesToWindows,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
|
type ProcessInfo,
|
||||||
|
type TmuxWindow,
|
||||||
} from "../../../hosts/tmux/monitor-helpers.js";
|
} from "../../../hosts/tmux/monitor-helpers.js";
|
||||||
|
|
||||||
function join(...fields: (string | number)[]): string {
|
function join(...fields: (string | number)[]): string {
|
||||||
@@ -194,6 +196,28 @@ describe("buildPaneMetrics", () => {
|
|||||||
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
||||||
expect(metrics[0].processCount).toBe(2);
|
expect(metrics[0].processCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aggregates a wide process tree without dropping children", () => {
|
||||||
|
const childCount = 2_000;
|
||||||
|
const wideTree: ProcessInfo[] = [
|
||||||
|
{ pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" },
|
||||||
|
...Array.from({ length: childCount }, (_, index) => ({
|
||||||
|
pid: index + 2,
|
||||||
|
ppid: 1,
|
||||||
|
cpu: 0.1,
|
||||||
|
mem: 0,
|
||||||
|
rss: 1,
|
||||||
|
comm: `worker-${index}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const pane = parsePanes(
|
||||||
|
join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [metrics] = buildPaneMetrics(pane, wideTree, new Map());
|
||||||
|
expect(metrics.processCount).toBe(childCount + 1);
|
||||||
|
expect(metrics.memRssKb).toBe(childCount + 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("attachPanesToWindows", () => {
|
describe("attachPanesToWindows", () => {
|
||||||
@@ -214,6 +238,29 @@ describe("attachPanesToWindows", () => {
|
|||||||
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
||||||
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
|
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves first-match behavior for duplicate window indexes", () => {
|
||||||
|
const first: TmuxWindow = {
|
||||||
|
index: 0,
|
||||||
|
name: "first",
|
||||||
|
active: true,
|
||||||
|
panes: [],
|
||||||
|
};
|
||||||
|
const duplicate: TmuxWindow = {
|
||||||
|
index: 0,
|
||||||
|
name: "duplicate",
|
||||||
|
active: false,
|
||||||
|
panes: [],
|
||||||
|
};
|
||||||
|
const windows = new Map([["s1", [first, duplicate]]]);
|
||||||
|
const panes = parsePanes(
|
||||||
|
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||||
|
);
|
||||||
|
|
||||||
|
attachPanesToWindows(windows, panes);
|
||||||
|
expect(first.panes).toHaveLength(1);
|
||||||
|
expect(duplicate.panes).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shellEscape", () => {
|
describe("shellEscape", () => {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { fetchWithProxy } from "../../utils/proxy-agent.js";
|
||||||
|
|
||||||
|
describe("fetchWithProxy", () => {
|
||||||
|
const savedProxies = {
|
||||||
|
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||||
|
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||||
|
http_proxy: process.env.http_proxy,
|
||||||
|
https_proxy: process.env.https_proxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const [name, value] of Object.entries(savedProxies)) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a dispatcher compatible with the selected fetch implementation", async () => {
|
||||||
|
delete process.env.HTTP_PROXY;
|
||||||
|
delete process.env.HTTPS_PROXY;
|
||||||
|
delete process.env.http_proxy;
|
||||||
|
delete process.env.https_proxy;
|
||||||
|
const server = createServer((_request, response) => response.end("ok"));
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
server.listen(0, "127.0.0.1", resolve),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("No port");
|
||||||
|
const response = await fetchWithProxy(`http://127.0.0.1:${address.port}`);
|
||||||
|
expect(await response.text()).toBe("ok");
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((error) => (error ? reject(error) : resolve())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
parseSSHKey,
|
parseSSHKey,
|
||||||
parsePublicKey,
|
parsePublicKey,
|
||||||
preparePrivateKeyForSSH2,
|
preparePrivateKeyForSSH2,
|
||||||
|
isPrivateKeyPassphraseError,
|
||||||
getFriendlyKeyTypeName,
|
getFriendlyKeyTypeName,
|
||||||
validateKeyPair,
|
validateKeyPair,
|
||||||
} from "../../utils/ssh-key-utils.js";
|
} from "../../utils/ssh-key-utils.js";
|
||||||
@@ -97,6 +98,22 @@ describe("parseSSHKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isPrivateKeyPassphraseError", () => {
|
||||||
|
it("recognizes missing and incorrect passphrase errors", () => {
|
||||||
|
expect(
|
||||||
|
isPrivateKeyPassphraseError(
|
||||||
|
new Error(
|
||||||
|
"Encrypted OpenSSH private key detected, but no passphrase given",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(isPrivateKeyPassphraseError(new Error("Bad passphrase"))).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPrivateKeyPassphraseError(new Error("Unsupported key format")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getFriendlyKeyTypeName", () => {
|
describe("getFriendlyKeyTypeName", () => {
|
||||||
it("maps known key types to friendly names", () => {
|
it("maps known key types to friendly names", () => {
|
||||||
expect(getFriendlyKeyTypeName("ssh-rsa")).toBe("RSA");
|
expect(getFriendlyKeyTypeName("ssh-rsa")).toBe("RSA");
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseWsMessage,
|
||||||
|
asObject,
|
||||||
|
asString,
|
||||||
|
toTerminalDimension,
|
||||||
|
WsMessageError,
|
||||||
|
} from "../../utils/ws-message.js";
|
||||||
|
|
||||||
|
const frame = (s: string) => Buffer.from(s, "utf8");
|
||||||
|
|
||||||
|
describe("parseWsMessage", () => {
|
||||||
|
it("parses a well-formed message", () => {
|
||||||
|
expect(parseWsMessage(frame('{"type":"ping"}'))).toEqual({
|
||||||
|
type: "ping",
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
expect(parseWsMessage(frame('{"type":"input","data":"ls"}'))).toEqual({
|
||||||
|
type: "input",
|
||||||
|
data: "ls",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects JSON that parses but cannot be destructured", () => {
|
||||||
|
// The original DoS: JSON.parse("null") succeeds, so it escaped the
|
||||||
|
// try/catch and threw a TypeError on destructure.
|
||||||
|
for (const payload of ["null", "123", '"str"', "[1,2]", "true"]) {
|
||||||
|
expect(() => parseWsMessage(frame(payload))).toThrow(WsMessageError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid JSON", () => {
|
||||||
|
expect(() => parseWsMessage(frame("{oops"))).toThrow(WsMessageError);
|
||||||
|
expect(() => parseWsMessage(frame(""))).toThrow(WsMessageError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or non-string type", () => {
|
||||||
|
expect(() => parseWsMessage(frame("{}"))).toThrow(WsMessageError);
|
||||||
|
expect(() => parseWsMessage(frame('{"type":5}'))).toThrow(WsMessageError);
|
||||||
|
expect(() => parseWsMessage(frame('{"type":null}'))).toThrow(
|
||||||
|
WsMessageError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized frames", () => {
|
||||||
|
const huge = Buffer.alloc(1024 * 1024 + 1, 0x20);
|
||||||
|
expect(() => parseWsMessage(huge)).toThrow(WsMessageError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws a TypeError for any malformed input", () => {
|
||||||
|
const payloads = [
|
||||||
|
"null",
|
||||||
|
"0",
|
||||||
|
"[]",
|
||||||
|
"{}",
|
||||||
|
'{"type":{}}',
|
||||||
|
'{"data":"x"}',
|
||||||
|
"undefined",
|
||||||
|
'{"type":"a","data":null}',
|
||||||
|
];
|
||||||
|
for (const p of payloads) {
|
||||||
|
try {
|
||||||
|
parseWsMessage(frame(p));
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(WsMessageError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("asObject / asString", () => {
|
||||||
|
it("narrows without throwing", () => {
|
||||||
|
expect(asObject({ a: 1 })).toEqual({ a: 1 });
|
||||||
|
expect(asObject(null)).toEqual({});
|
||||||
|
expect(asObject([1])).toEqual({});
|
||||||
|
expect(asObject("x")).toEqual({});
|
||||||
|
expect(asString("x")).toBe("x");
|
||||||
|
expect(asString(5)).toBe("");
|
||||||
|
expect(asString(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toTerminalDimension", () => {
|
||||||
|
it("accepts sane values", () => {
|
||||||
|
expect(toTerminalDimension(80)).toBe(80);
|
||||||
|
expect(toTerminalDimension("120")).toBe(120);
|
||||||
|
expect(toTerminalDimension(24.7)).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects values that would poison setWindow", () => {
|
||||||
|
for (const bad of [0, -1, NaN, Infinity, null, undefined, "abc", {}]) {
|
||||||
|
expect(toTerminalDimension(bad)).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps absurdly large values", () => {
|
||||||
|
expect(toTerminalDimension(1e9)).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Agent, ProxyAgent } from "undici";
|
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||||
import type { Dispatcher } from "undici-types";
|
import type { Dispatcher } from "undici-types";
|
||||||
|
|
||||||
const directAgent = new Agent({
|
const directAgent = new Agent({
|
||||||
@@ -39,3 +39,13 @@ export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
|
|||||||
export function getFetchDispatcher(targetUrl: string): Dispatcher {
|
export function getFetchDispatcher(targetUrl: string): Dispatcher {
|
||||||
return getProxyAgent(targetUrl) ?? (directAgent as unknown as Dispatcher);
|
return getProxyAgent(targetUrl) ?? (directAgent as unknown as Dispatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchWithProxy(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return undiciFetch(url, {
|
||||||
|
...init,
|
||||||
|
dispatcher: getFetchDispatcher(url),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -256,6 +256,10 @@ export function preparePrivateKeyForSSH2(
|
|||||||
return Buffer.from(cleanKey, "utf8");
|
return Buffer.from(cleanKey, "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPrivateKeyPassphraseError(error: unknown): boolean {
|
||||||
|
return /passphrase/i.test(getErrorMessage(error, ""));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSSHKey(
|
export function parseSSHKey(
|
||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { RawData } from "ws";
|
||||||
|
|
||||||
|
// Cap on a single decoded text frame. Anything larger is almost certainly
|
||||||
|
// abuse - the legitimate control messages here are tiny, and terminal input is
|
||||||
|
// bounded by what a user can type or paste.
|
||||||
|
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
export class WsMessageError extends Error {}
|
||||||
|
|
||||||
|
function rawByteLength(raw: RawData): number {
|
||||||
|
if (Buffer.isBuffer(raw)) return raw.length;
|
||||||
|
if (Array.isArray(raw))
|
||||||
|
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a WebSocket frame into a plain message object.
|
||||||
|
*
|
||||||
|
* Throws WsMessageError - never a TypeError - for anything malformed, so
|
||||||
|
* callers can reject the frame instead of the parse blowing up the handler.
|
||||||
|
* `JSON.parse` happily returns null, numbers and arrays, none of which are
|
||||||
|
* safe to destructure, so the shape is checked here rather than at each site.
|
||||||
|
*/
|
||||||
|
export function parseWsMessage(raw: RawData): {
|
||||||
|
type: string;
|
||||||
|
data: unknown;
|
||||||
|
} {
|
||||||
|
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
|
||||||
|
throw new WsMessageError("Message too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
throw new WsMessageError("Invalid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new WsMessageError("Message must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||||
|
if (typeof type !== "string") {
|
||||||
|
throw new WsMessageError("Message type must be a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow unknown payload data to a plain object without throwing. */
|
||||||
|
export function asObject(data: unknown): Record<string, unknown> {
|
||||||
|
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||||
|
? (data as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow unknown payload data to a string without throwing. */
|
||||||
|
export function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a client-supplied terminal width/height to a sane integer.
|
||||||
|
* Returns 0 when the value is unusable, so callers can skip the resize
|
||||||
|
* rather than pass NaN or a negative into ssh2's setWindow.
|
||||||
|
*/
|
||||||
|
export function toTerminalDimension(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
const rounded = Math.floor(n);
|
||||||
|
if (rounded < 1) return 0;
|
||||||
|
return Math.min(rounded, 10000);
|
||||||
|
}
|
||||||
Vendored
+5
-1
@@ -40,7 +40,10 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
getServerConfig: () => Promise<ServerConfig>;
|
getServerConfig: () => Promise<ServerConfig>;
|
||||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||||
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
|
testServerConnection: (
|
||||||
|
serverUrl: string,
|
||||||
|
allowInvalidCertificate?: boolean,
|
||||||
|
) => Promise<ConnectionTestResult>;
|
||||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||||
saveC2STunnelConfig: (
|
saveC2STunnelConfig: (
|
||||||
config: unknown[],
|
config: unknown[],
|
||||||
@@ -171,6 +174,7 @@ export interface ElectronAPI {
|
|||||||
startLocalTerminal(dimensions: {
|
startLocalTerminal(dimensions: {
|
||||||
cols: number;
|
cols: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
|
shell?: "default" | "wsl";
|
||||||
}): Promise<{ sessionId: string; shell: string }>;
|
}): Promise<{ sessionId: string; shell: string }>;
|
||||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
||||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
||||||
|
|||||||
@@ -715,6 +715,7 @@ export interface TerminalConfig {
|
|||||||
linkClickBehavior?: "confirm" | "direct";
|
linkClickBehavior?: "confirm" | "direct";
|
||||||
useSSHTitle?: boolean;
|
useSSHTitle?: boolean;
|
||||||
agentSocketPath?: string;
|
agentSocketPath?: string;
|
||||||
|
agentIdentity?: string;
|
||||||
customThemeColors?: {
|
customThemeColors?: {
|
||||||
background: string;
|
background: string;
|
||||||
foreground: string;
|
foreground: string;
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ export async function updateCredential(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateCredential(
|
||||||
|
credentialId: number,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(
|
||||||
|
`/credentials/${credentialId}/duplicate`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "duplicate credential");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteCredential(
|
export async function deleteCredential(
|
||||||
credentialId: number,
|
credentialId: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||||
|
import { resolveRemoteHostId } from "@/lib/remote-server-api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The embedded desktop backend does not bundle guacd, which is why
|
* The embedded desktop backend does not bundle guacd, which is why
|
||||||
@@ -185,10 +186,18 @@ export async function getGuacamoleTokenFromHost(
|
|||||||
password?: string;
|
password?: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
},
|
},
|
||||||
|
syncId?: string | null,
|
||||||
): Promise<GuacamoleTokenResponse> {
|
): Promise<GuacamoleTokenResponse> {
|
||||||
try {
|
try {
|
||||||
|
const remoteHostId = isElectron()
|
||||||
|
? await resolveRemoteHostId(syncId)
|
||||||
|
: null;
|
||||||
|
if (isElectron() && syncId && remoteHostId === null) {
|
||||||
|
throw new Error("The synced host does not exist on the remote server");
|
||||||
|
}
|
||||||
|
const targetHostId = remoteHostId ?? hostId;
|
||||||
const response = await guacamoleApi().post(
|
const response = await guacamoleApi().post(
|
||||||
`/guacamole/connect-host/${hostId}`,
|
`/guacamole/connect-host/${targetHostId}`,
|
||||||
{
|
{
|
||||||
...(protocol ? { protocol } : {}),
|
...(protocol ? { protocol } : {}),
|
||||||
...(promptedCredentials?.username
|
...(promptedCredentials?.username
|
||||||
|
|||||||
@@ -549,14 +549,26 @@ export async function downloadSSHFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadProgressEvent {
|
||||||
|
loaded: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadSSHFileStream(
|
export async function downloadSSHFileStream(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
onProgress?: (event: DownloadProgressEvent) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const response = await getFileManagerApiForSession(sessionId).post(
|
const response = await getFileManagerApiForSession(sessionId).post(
|
||||||
"/ssh/downloadFileStream",
|
"/ssh/downloadFileStream",
|
||||||
{ sessionId, path: filePath },
|
{ sessionId, path: filePath },
|
||||||
{ responseType: "blob", timeout: 0 },
|
{
|
||||||
|
responseType: "blob",
|
||||||
|
timeout: 0,
|
||||||
|
onDownloadProgress: onProgress
|
||||||
|
? (event) => onProgress({ loaded: event.loaded, total: event.total })
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const blob = response.data as Blob;
|
const blob = response.data as Blob;
|
||||||
const fileName = filePath.split("/").pop() || "download";
|
const fileName = filePath.split("/").pop() || "download";
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export async function updateOidcAutoProvision(
|
|||||||
|
|
||||||
export async function getOidcSilentLoginDefault(): Promise<{
|
export async function getOidcSilentLoginDefault(): Promise<{
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
locked?: boolean;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/users/oidc-silent-login-default");
|
const response = await authApi.get("/users/oidc-silent-login-default");
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
PublicKeyCredentialCreationOptionsJSON,
|
PublicKeyCredentialCreationOptionsJSON,
|
||||||
|
PublicKeyCredentialRequestOptionsJSON,
|
||||||
RegistrationResponseJSON,
|
RegistrationResponseJSON,
|
||||||
} from "@simplewebauthn/browser";
|
} from "@simplewebauthn/browser";
|
||||||
import { startRegistration } from "@simplewebauthn/browser";
|
import {
|
||||||
|
browserSupportsWebAuthn,
|
||||||
|
startAuthentication,
|
||||||
|
startRegistration,
|
||||||
|
} from "@simplewebauthn/browser";
|
||||||
import { authApi, handleApiError } from "@/main-axios";
|
import { authApi, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||||
@@ -23,6 +29,53 @@ type RegistrationOptionsResponse = {
|
|||||||
challengeId: string;
|
challengeId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AuthenticationOptionsResponse = {
|
||||||
|
options: PublicKeyCredentialRequestOptionsJSON;
|
||||||
|
challengeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PasskeyLoginResult = {
|
||||||
|
success: boolean;
|
||||||
|
requires_totp?: boolean;
|
||||||
|
temp_token?: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
username?: string;
|
||||||
|
userId?: string;
|
||||||
|
is_oidc?: boolean;
|
||||||
|
totp_enabled?: boolean;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isPasskeySupported(): boolean {
|
||||||
|
return browserSupportsWebAuthn();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithPasskey(
|
||||||
|
username?: string,
|
||||||
|
rememberMe = false,
|
||||||
|
): Promise<PasskeyLoginResult> {
|
||||||
|
try {
|
||||||
|
const optionsResponse = await authApi.post<AuthenticationOptionsResponse>(
|
||||||
|
"/users/webauthn/authenticate/options",
|
||||||
|
username ? { username } : {},
|
||||||
|
);
|
||||||
|
const credential = await startAuthentication({
|
||||||
|
optionsJSON: optionsResponse.data.options,
|
||||||
|
});
|
||||||
|
const verifyResponse = await authApi.post<PasskeyLoginResult>(
|
||||||
|
"/users/webauthn/authenticate/verify",
|
||||||
|
{
|
||||||
|
challengeId: optionsResponse.data.challengeId,
|
||||||
|
response: credential as AuthenticationResponseJSON,
|
||||||
|
rememberMe,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return verifyResponse.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "sign in with passkey");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWebAuthnCredentials(): Promise<{
|
export async function listWebAuthnCredentials(): Promise<{
|
||||||
credentials: WebAuthnCredentialSummary[];
|
credentials: WebAuthnCredentialSummary[];
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Fingerprint,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
requestTrustedProxyLogin,
|
requestTrustedProxyLogin,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||||
|
import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api";
|
||||||
import type { SSOProviderPublic } from "@/types/index";
|
import type { SSOProviderPublic } from "@/types/index";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -217,6 +219,13 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
const [providerLoading, setProviderLoading] = useState<
|
const [providerLoading, setProviderLoading] = useState<
|
||||||
Record<number, boolean>
|
Record<number, boolean>
|
||||||
>({});
|
>({});
|
||||||
|
const [passkeySupported] = useState(() => {
|
||||||
|
try {
|
||||||
|
return isPasskeySupported();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -635,6 +644,72 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePasskeyLogin() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await loginWithPasskey(
|
||||||
|
username.trim() || undefined,
|
||||||
|
rememberMe,
|
||||||
|
);
|
||||||
|
if (res.requires_totp) {
|
||||||
|
setTotpTempToken(res.temp_token ?? "");
|
||||||
|
setView("totp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res?.success) throw new Error(t("auth.passkeyLoginFailed"));
|
||||||
|
if (isInMobileWebView()) {
|
||||||
|
const token = res?.token ?? "";
|
||||||
|
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
||||||
|
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
||||||
|
);
|
||||||
|
setWebviewAuthSuccess(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isInElectronWebView()) {
|
||||||
|
// Same as handleLogin: the iframe never sends X-Electron-App, so read
|
||||||
|
// the JWT back from the cookie that was just set.
|
||||||
|
const token = res?.token ?? (await getCurrentToken());
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: "AUTH_SUCCESS",
|
||||||
|
source: "passkey_auth_component",
|
||||||
|
platform: "desktop",
|
||||||
|
token: token ?? null,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
setWebviewAuthSuccess(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meRes = await getUserInfo();
|
||||||
|
storeAuth(meRes.username || res.username || "");
|
||||||
|
toast.success(t("messages.loginSuccess"));
|
||||||
|
onLogin(
|
||||||
|
meRes.username || res.username || "",
|
||||||
|
meRes.userId || undefined,
|
||||||
|
!!meRes.is_admin,
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as {
|
||||||
|
name?: string;
|
||||||
|
message?: string;
|
||||||
|
response?: { data?: { error?: string } };
|
||||||
|
};
|
||||||
|
// Closing or cancelling the browser prompt is not a failure worth a toast.
|
||||||
|
if (error?.name === "NotAllowedError" || error?.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
error?.response?.data?.error ||
|
||||||
|
error?.message ||
|
||||||
|
t("auth.passkeyLoginFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRegister(e: React.FormEvent) {
|
async function handleRegister(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username.trim()) {
|
if (!username.trim()) {
|
||||||
@@ -1469,6 +1544,20 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{passkeySupported && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-10 font-bold"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Fingerprint className="size-4" />
|
||||||
|
{t("auth.signInWithPasskey")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1536,6 +1625,20 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{passkeySupported && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-10 font-bold"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Fingerprint className="size-4" />
|
||||||
|
{t("auth.signInWithPasskey")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ export function ProxmoxDiscoverDialog({
|
|||||||
// the real IP. Re-sync keeps the manual value (guest.ip || existing.ip).
|
// the real IP. Re-sync keeps the manual value (guest.ip || existing.ip).
|
||||||
ip: g.ip || "0.0.0.0",
|
ip: g.ip || "0.0.0.0",
|
||||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||||
username: defaultUsername ?? "root",
|
username:
|
||||||
|
importAuth.authType === "credential"
|
||||||
|
? ""
|
||||||
|
: (defaultUsername ?? "root"),
|
||||||
folder: importFolder,
|
folder: importFolder,
|
||||||
// Inherit the jump-host chain from the scanned Proxmox host so the
|
// Inherit the jump-host chain from the scanned Proxmox host so the
|
||||||
// imported guests are reachable the same way; user can override.
|
// imported guests are reachable the same way; user can override.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function resolveProxmoxImportAuth(
|
|||||||
return {
|
return {
|
||||||
authType: "credential",
|
authType: "credential",
|
||||||
credentialId,
|
credentialId,
|
||||||
overrideCredentialUsername: true,
|
overrideCredentialUsername: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getErrorMessage } from "../../lib/error-message.js";
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import { Label } from "@/components/label";
|
import { Label } from "@/components/label";
|
||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
createAiProvider,
|
createAiProvider,
|
||||||
deleteAiProvider,
|
deleteAiProvider,
|
||||||
|
getAiProviderModels,
|
||||||
probeAiModels,
|
probeAiModels,
|
||||||
|
updateAiProvider,
|
||||||
type AiProvider,
|
type AiProvider,
|
||||||
type AiProviderType,
|
type AiProviderType,
|
||||||
} from "@/api/ai-api";
|
} from "@/api/ai-api";
|
||||||
@@ -68,6 +70,159 @@ interface AiProviderSettingsProps {
|
|||||||
onAdded?: () => void;
|
onAdded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AiProviderEditForm({
|
||||||
|
provider,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
provider: AiProvider;
|
||||||
|
onSaved: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [label, setLabel] = useState(provider.label);
|
||||||
|
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
|
||||||
|
const [models, setModels] = useState<string[]>([]);
|
||||||
|
const [customModel, setCustomModel] = useState(false);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const detectModels = useCallback(async () => {
|
||||||
|
setDetecting(true);
|
||||||
|
try {
|
||||||
|
const detected = await getAiProviderModels(provider.id);
|
||||||
|
setModels(detected);
|
||||||
|
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||||
|
} catch {
|
||||||
|
setModels([]);
|
||||||
|
setCustomModel(true);
|
||||||
|
} finally {
|
||||||
|
setDetecting(false);
|
||||||
|
}
|
||||||
|
}, [provider.id, defaultModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void detectModels();
|
||||||
|
// The initial model value belongs to this provider. Subsequent edits must
|
||||||
|
// not trigger a provider model-list request on every keystroke.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [provider.id]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!label.trim()) {
|
||||||
|
toast.error(t("ai.labelRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateAiProvider(provider.id, {
|
||||||
|
label: label.trim(),
|
||||||
|
defaultModel: defaultModel.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success(t("ai.providerUpdated"));
|
||||||
|
onSaved();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-none border border-border p-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`ai-provider-label-${provider.id}`}>
|
||||||
|
{t("ai.providerLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`ai-provider-label-${provider.id}`}
|
||||||
|
className="rounded-none"
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor={`ai-provider-model-${provider.id}`}
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
>
|
||||||
|
{t("ai.defaultModel")}
|
||||||
|
</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||||
|
onClick={() => void detectModels()}
|
||||||
|
disabled={detecting}
|
||||||
|
>
|
||||||
|
{detecting ? (
|
||||||
|
<Loader2 size={11} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw size={11} />
|
||||||
|
)}
|
||||||
|
{t("ai.modelRefresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{models.length > 0 && !customModel ? (
|
||||||
|
<Select
|
||||||
|
value={defaultModel || undefined}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "__custom__") {
|
||||||
|
setCustomModel(true);
|
||||||
|
setDefaultModel("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDefaultModel(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={`ai-provider-model-${provider.id}`}
|
||||||
|
className="rounded-none"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models.map((model) => (
|
||||||
|
<SelectItem key={model} value={model}>
|
||||||
|
{model}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`ai-provider-model-${provider.id}`}
|
||||||
|
className="rounded-none"
|
||||||
|
value={defaultModel}
|
||||||
|
onChange={(event) => setDefaultModel(event.target.value)}
|
||||||
|
placeholder={t("ai.defaultModelPlaceholder")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
|
||||||
|
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t("ai.save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{t("ai.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AiProviderSettings({
|
export function AiProviderSettings({
|
||||||
providers,
|
providers,
|
||||||
onChanged,
|
onChanged,
|
||||||
@@ -75,6 +230,7 @@ export function AiProviderSettings({
|
|||||||
}: AiProviderSettingsProps) {
|
}: AiProviderSettingsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
@@ -173,19 +329,45 @@ export function AiProviderSettings({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) =>
|
||||||
|
editingId === provider.id ? (
|
||||||
|
<AiProviderEditForm
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
onChanged(provider.id);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{provider.label}</div>
|
<div className="truncate text-sm font-medium">
|
||||||
|
{provider.label}
|
||||||
|
</div>
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{provider.providerType}
|
{provider.providerType}
|
||||||
|
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
|
||||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(provider.id);
|
||||||
|
}}
|
||||||
|
aria-label={t("ai.editProvider")}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -195,10 +377,19 @@ export function AiProviderSettings({
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{!adding && (
|
{!adding && (
|
||||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
{t("ai.addProvider")}
|
{t("ai.addProvider")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
useWindowManager,
|
useWindowManager,
|
||||||
} from "./components/WindowManager.tsx";
|
} from "./components/WindowManager.tsx";
|
||||||
import { FileWindow } from "./components/FileWindow.tsx";
|
import { FileWindow } from "./components/FileWindow.tsx";
|
||||||
|
import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
|
||||||
import { DiffWindow } from "./components/DiffWindow.tsx";
|
import { DiffWindow } from "./components/DiffWindow.tsx";
|
||||||
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
|
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
|
||||||
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
|
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
|
||||||
@@ -976,19 +977,14 @@ function FileManagerContent({
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [currentPath]);
|
}, [currentPath]);
|
||||||
|
|
||||||
async function handleItemsDropped(items: DataTransferItemList) {
|
async function handleItemsDropped(entries: FileSystemEntry[]) {
|
||||||
if (!sshSessionId) {
|
if (!sshSessionId) {
|
||||||
toast.error(t("fileManager.noSSHConnection"));
|
toast.error(t("fileManager.noSSHConnection"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: FileSystemEntry[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const entry = items[i].webkitGetAsEntry?.();
|
|
||||||
if (entry) entries.push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
const files: { file: File; relativePath: string }[] = [];
|
const files: { file: File; relativePath: string }[] = [];
|
||||||
|
const emptyDirs: string[] = [];
|
||||||
|
|
||||||
async function readEntry(
|
async function readEntry(
|
||||||
entry: FileSystemEntry,
|
entry: FileSystemEntry,
|
||||||
@@ -999,51 +995,77 @@ function FileManagerContent({
|
|||||||
(entry as FileSystemFileEntry).file(resolve, reject),
|
(entry as FileSystemFileEntry).file(resolve, reject),
|
||||||
);
|
);
|
||||||
files.push({ file, relativePath: path });
|
files.push({ file, relativePath: path });
|
||||||
} else if (entry.isDirectory) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isDirectory) return;
|
||||||
|
|
||||||
|
// readEntries only hands back a page at a time and signals the end with an
|
||||||
|
// empty batch, so drain it fully before walking into the children.
|
||||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||||
let batch: FileSystemEntry[];
|
const children: FileSystemEntry[] = [];
|
||||||
do {
|
for (;;) {
|
||||||
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
||||||
reader.readEntries(resolve, reject),
|
reader.readEntries(resolve, reject),
|
||||||
);
|
);
|
||||||
for (const child of batch) {
|
if (batch.length === 0) break;
|
||||||
await readEntry(child, `${path}/${child.name}`);
|
children.push(...batch);
|
||||||
}
|
}
|
||||||
} while (batch.length > 0);
|
|
||||||
|
if (children.length === 0) {
|
||||||
|
emptyDirs.push(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
await readEntry(child, `${path}/${child.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
await readEntry(entry, entry.name);
|
await readEntry(entry, entry.name);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("fileManager.failedToUploadFile"));
|
||||||
|
console.error("Failed to read dropped folder:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length === 0) return;
|
if (files.length === 0 && emptyDirs.length === 0) return;
|
||||||
|
|
||||||
const progressToast = toast.loading(
|
const progressToast = toast.loading(
|
||||||
`Uploading ${files.length} file(s)...`,
|
t("fileManager.uploadingFolderFiles", { count: files.length }),
|
||||||
{ duration: Infinity },
|
{ duration: Infinity },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
|
const base = currentPath.endsWith("/") ? currentPath : currentPath + "/";
|
||||||
|
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
for (const { relativePath } of files) {
|
for (const relativePath of [
|
||||||
|
...files.map((f) => f.relativePath),
|
||||||
|
...emptyDirs.map((d) => `${d}/`),
|
||||||
|
]) {
|
||||||
const parts = relativePath.split("/");
|
const parts = relativePath.split("/");
|
||||||
for (let i = 1; i < parts.length; i++) {
|
for (let i = 1; i < parts.length; i++) {
|
||||||
dirs.add(parts.slice(0, i).join("/"));
|
dirs.add(parts.slice(0, i).join("/"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedDirs = Array.from(dirs).sort();
|
// Shallowest first so each parent exists before its children.
|
||||||
|
const sortedDirs = Array.from(dirs).sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.split("/").length - b.split("/").length || a.localeCompare(b),
|
||||||
|
);
|
||||||
for (const dir of sortedDirs) {
|
for (const dir of sortedDirs) {
|
||||||
const parentPath = currentPath.endsWith("/")
|
const parentDir = dir.split("/").slice(0, -1).join("/");
|
||||||
? currentPath + dir.split("/").slice(0, -1).join("/")
|
const targetPath = parentDir ? `${base}${parentDir}/` : base;
|
||||||
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
|
|
||||||
const folderName = dir.split("/").pop()!;
|
const folderName = dir.split("/").pop()!;
|
||||||
const targetPath = parentPath.endsWith("/")
|
|
||||||
? parentPath
|
|
||||||
: parentPath + "/";
|
|
||||||
try {
|
try {
|
||||||
await createSSHFolder(
|
await createSSHFolder(
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
@@ -1060,12 +1082,9 @@ function FileManagerContent({
|
|||||||
const dirPart = relativePath.includes("/")
|
const dirPart = relativePath.includes("/")
|
||||||
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
||||||
: "";
|
: "";
|
||||||
const uploadPath = dirPart
|
const uploadPath = dirPart ? `${base}${dirPart}/` : currentPath;
|
||||||
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
|
|
||||||
dirPart +
|
|
||||||
"/"
|
|
||||||
: currentPath;
|
|
||||||
|
|
||||||
|
try {
|
||||||
await uploadSSHFile(
|
await uploadSSHFile(
|
||||||
sshSessionId,
|
sshSessionId,
|
||||||
uploadPath,
|
uploadPath,
|
||||||
@@ -1073,10 +1092,27 @@ function FileManagerContent({
|
|||||||
file,
|
file,
|
||||||
currentHost?.id,
|
currentHost?.id,
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
failed.push(relativePath);
|
||||||
|
console.error(`Failed to upload ${relativePath}:`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
toast.success(`Uploaded ${files.length} file(s) successfully`);
|
if (failed.length === 0) {
|
||||||
|
toast.success(
|
||||||
|
t("fileManager.uploadedFolderFiles", { count: files.length }),
|
||||||
|
);
|
||||||
|
} else if (failed.length === files.length) {
|
||||||
|
toast.error(t("fileManager.failedToUploadFile"));
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
t("fileManager.uploadedFolderPartial", {
|
||||||
|
uploaded: files.length - failed.length,
|
||||||
|
failed: failed.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
handleRefreshDirectory();
|
handleRefreshDirectory();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.dismiss(progressToast);
|
toast.dismiss(progressToast);
|
||||||
@@ -1166,14 +1202,51 @@ function FileManagerContent({
|
|||||||
async function handleDownloadFile(file: FileItem) {
|
async function handleDownloadFile(file: FileItem) {
|
||||||
if (!sshSessionId) return;
|
if (!sshSessionId) return;
|
||||||
|
|
||||||
|
const toastId = `download-${file.path}-${Date.now()}`;
|
||||||
|
let lastLoaded = 0;
|
||||||
|
let lastTime = Date.now();
|
||||||
|
let mbPerSec: number | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureSSHConnection();
|
await ensureSSHConnection();
|
||||||
|
|
||||||
const { downloadSSHFileStream } = await import("@/main-axios.ts");
|
const { downloadSSHFileStream } = await import("@/main-axios.ts");
|
||||||
await downloadSSHFileStream(sshSessionId, file.path);
|
|
||||||
|
toast.loading(<DownloadProgressToast fileName={file.name} loaded={0} />, {
|
||||||
|
id: toastId,
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
await downloadSSHFileStream(
|
||||||
|
sshSessionId,
|
||||||
|
file.path,
|
||||||
|
({ loaded, total }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const deltaMs = now - lastTime;
|
||||||
|
if (deltaMs > 200) {
|
||||||
|
const deltaBytes = loaded - lastLoaded;
|
||||||
|
if (deltaBytes >= 0) {
|
||||||
|
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
|
||||||
|
}
|
||||||
|
lastLoaded = loaded;
|
||||||
|
lastTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading(
|
||||||
|
<DownloadProgressToast
|
||||||
|
fileName={file.name}
|
||||||
|
loaded={loaded}
|
||||||
|
total={total}
|
||||||
|
mbPerSec={mbPerSec}
|
||||||
|
/>,
|
||||||
|
{ id: toastId, duration: Infinity },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||||
|
{ id: toastId },
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error instanceof Error ? error : null;
|
const err = error instanceof Error ? error : null;
|
||||||
@@ -1187,9 +1260,10 @@ function FileManagerContent({
|
|||||||
ip: currentHost?.ip,
|
ip: currentHost?.ip,
|
||||||
port: currentHost?.port,
|
port: currentHost?.port,
|
||||||
}),
|
}),
|
||||||
|
{ id: toastId },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("fileManager.failedToDownloadFile"));
|
toast.error(t("fileManager.failedToDownloadFile"), { id: toastId });
|
||||||
}
|
}
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -76,14 +76,20 @@ function Breadcrumb({
|
|||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
{part === "" && i === 0 ? (
|
{part === "" && i === 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateTo("/")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigateTo("/");
|
||||||
|
}}
|
||||||
className="hover:text-accent-brand transition-colors"
|
className="hover:text-accent-brand transition-colors"
|
||||||
>
|
>
|
||||||
{t("fileManager.root")}
|
{t("fileManager.root")}
|
||||||
</button>
|
</button>
|
||||||
) : part !== "" ? (
|
) : part !== "" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateTo(arr.slice(0, i + 1).join("/") || "/")}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigateTo(arr.slice(0, i + 1).join("/") || "/");
|
||||||
|
}}
|
||||||
className="hover:text-accent-brand transition-colors"
|
className="hover:text-accent-brand transition-colors"
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
@@ -102,6 +108,83 @@ function Breadcrumb({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PathBar({
|
||||||
|
currentPath,
|
||||||
|
navigateTo,
|
||||||
|
t,
|
||||||
|
className,
|
||||||
|
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
|
||||||
|
className: string;
|
||||||
|
}) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(currentPath);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEditing) return;
|
||||||
|
doneRef.current = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const commit = (path: string) => {
|
||||||
|
if (doneRef.current) return;
|
||||||
|
doneRef.current = true;
|
||||||
|
setIsEditing(false);
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (trimmed && trimmed !== currentPath) {
|
||||||
|
navigateTo(trimmed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (doneRef.current) return;
|
||||||
|
doneRef.current = true;
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(value);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => commit(value)}
|
||||||
|
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${className} cursor-text`}
|
||||||
|
onClick={() => {
|
||||||
|
setValue(currentPath);
|
||||||
|
setIsEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FileManagerToolbar({
|
export function FileManagerToolbar({
|
||||||
t,
|
t,
|
||||||
currentPath,
|
currentPath,
|
||||||
@@ -182,9 +265,12 @@ export function FileManagerToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden">
|
<PathBar
|
||||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
currentPath={currentPath}
|
||||||
</div>
|
navigateTo={navigateTo}
|
||||||
|
t={t}
|
||||||
|
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
@@ -340,9 +426,12 @@ export function FileManagerToolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
|
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
|
||||||
<div className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden">
|
<PathBar
|
||||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
currentPath={currentPath}
|
||||||
</div>
|
navigateTo={navigateTo}
|
||||||
|
t={t}
|
||||||
|
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { formatTransferMbPerSec } from "@/main-axios.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface DownloadProgressToastProps {
|
||||||
|
fileName: string;
|
||||||
|
loaded: number;
|
||||||
|
total?: number;
|
||||||
|
mbPerSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
const formattedSize =
|
||||||
|
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||||
|
return `${formattedSize} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndeterminateProgressBar() {
|
||||||
|
return (
|
||||||
|
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||||
|
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeterminateProgressBar({ value }: { value: number }) {
|
||||||
|
const clamped = Math.min(100, Math.max(0, value));
|
||||||
|
return (
|
||||||
|
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full rounded-full transition-[width]"
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadProgressToast({
|
||||||
|
fileName,
|
||||||
|
loaded,
|
||||||
|
total,
|
||||||
|
mbPerSec,
|
||||||
|
}: DownloadProgressToastProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const percent =
|
||||||
|
total !== undefined && total > 0
|
||||||
|
? Math.min(100, Math.round((loaded / total) * 100))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const speed = formatTransferMbPerSec(mbPerSec);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||||
|
<p className="text-sm font-medium leading-tight truncate">
|
||||||
|
{t("fileManager.downloadingFile", { name: fileName })}
|
||||||
|
</p>
|
||||||
|
{percent === undefined ? (
|
||||||
|
<IndeterminateProgressBar />
|
||||||
|
) : (
|
||||||
|
<DeterminateProgressBar value={percent} />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{total !== undefined
|
||||||
|
? t("fileManager.downloadProgressBytes", {
|
||||||
|
transferred: formatBytes(loaded),
|
||||||
|
total: formatBytes(total),
|
||||||
|
})
|
||||||
|
: formatBytes(loaded)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 tabular-nums ${speed ? "font-medium text-foreground" : "invisible"}`}
|
||||||
|
aria-hidden={!speed}
|
||||||
|
>
|
||||||
|
{speed || "0 MB/s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ interface DragAndDropState {
|
|||||||
|
|
||||||
interface UseDragAndDropProps {
|
interface UseDragAndDropProps {
|
||||||
onFilesDropped: (files: FileList) => void;
|
onFilesDropped: (files: FileList) => void;
|
||||||
onItemsDropped?: (items: DataTransferItemList) => void;
|
onItemsDropped?: (entries: FileSystemEntry[]) => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
maxFileSize?: number;
|
maxFileSize?: number;
|
||||||
allowedTypes?: string[];
|
allowedTypes?: string[];
|
||||||
@@ -119,23 +119,28 @@ export function useDragAndDrop({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Read the entries before touching state. Updating state flushes a render
|
||||||
|
// and the browser clears dataTransfer once the drop handler unwinds, so
|
||||||
|
// anything read later comes back empty (Firefox is strictest here).
|
||||||
|
const entries: FileSystemEntry[] = [];
|
||||||
|
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
||||||
|
for (const item of Array.from(e.dataTransfer.items)) {
|
||||||
|
const entry = item.webkitGetAsEntry?.();
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragCounter: 0,
|
dragCounter: 0,
|
||||||
draggedFiles: [],
|
draggedFiles: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
|
||||||
const hasDirectory = Array.from(e.dataTransfer.items).some(
|
onItemsDropped(entries);
|
||||||
(item) => item.webkitGetAsEntry?.()?.isDirectory,
|
|
||||||
);
|
|
||||||
if (hasDirectory) {
|
|
||||||
onItemsDropped(e.dataTransfer.items);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type Guacamole from "guacamole-common-js";
|
import type Guacamole from "guacamole-common-js";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
GuacamoleDisplay,
|
GuacamoleDisplay,
|
||||||
type GuacamoleDisplayHandle,
|
type GuacamoleDisplayHandle,
|
||||||
@@ -133,7 +134,7 @@ interface GuacamoleAppInnerProps {
|
|||||||
hostId: number;
|
hostId: number;
|
||||||
hostConfig: Pick<
|
hostConfig: Pick<
|
||||||
SSHHost,
|
SSHHost,
|
||||||
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType"
|
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType" | "syncId"
|
||||||
>;
|
>;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
tabId?: string;
|
tabId?: string;
|
||||||
@@ -180,6 +181,16 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
setFileBrowserOpen(true);
|
setFileBrowserOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDropUnavailable = useCallback(() => {
|
||||||
|
toast.error(
|
||||||
|
t(
|
||||||
|
allowUpload
|
||||||
|
? "guacamole.files.driveUnavailable"
|
||||||
|
: "guacamole.files.uploadDisabled",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [allowUpload, t]);
|
||||||
|
|
||||||
const resolvedProtocolForConnect = (protocol ??
|
const resolvedProtocolForConnect = (protocol ??
|
||||||
hostConfig.connectionType ??
|
hostConfig.connectionType ??
|
||||||
"rdp") as "rdp" | "vnc" | "telnet";
|
"rdp") as "rdp" | "vnc" | "telnet";
|
||||||
@@ -245,6 +256,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
hostId,
|
hostId,
|
||||||
protocol,
|
protocol,
|
||||||
promptedCredentials ?? undefined,
|
promptedCredentials ?? undefined,
|
||||||
|
hostConfig.syncId,
|
||||||
);
|
);
|
||||||
if (result) {
|
if (result) {
|
||||||
setToken(result.token);
|
setToken(result.token);
|
||||||
@@ -257,6 +269,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
protocol,
|
protocol,
|
||||||
promptedCredentials,
|
promptedCredentials,
|
||||||
resolvedProtocolForConnect,
|
resolvedProtocolForConnect,
|
||||||
|
hostConfig.syncId,
|
||||||
addLog,
|
addLog,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
@@ -461,6 +474,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
}
|
}
|
||||||
onFilesystem={setFilesystem}
|
onFilesystem={setFilesystem}
|
||||||
onDropFiles={handleDropFiles}
|
onDropFiles={handleDropFiles}
|
||||||
|
onDropUnavailable={handleDropUnavailable}
|
||||||
/>
|
/>
|
||||||
{filesystem && fileBrowserOpen && (
|
{filesystem && fileBrowserOpen && (
|
||||||
<GuacamoleFileBrowser
|
<GuacamoleFileBrowser
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
} from "./guacamole-clipboard.ts";
|
} from "./guacamole-clipboard.ts";
|
||||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||||
|
import {
|
||||||
|
getFileDropDisposition,
|
||||||
|
hasDraggedFiles,
|
||||||
|
} from "./guacamole-file-drop.ts";
|
||||||
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
import { guacStateToStage } from "@/components/connection/connection-status.ts";
|
||||||
import type { ConnectionStage } from "@/types/connection-log.ts";
|
import type { ConnectionStage } from "@/types/connection-log.ts";
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ interface GuacamoleDisplayProps {
|
|||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
|
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
|
||||||
onDropFiles?: (files: File[]) => void;
|
onDropFiles?: (files: File[]) => void;
|
||||||
|
onDropUnavailable?: () => void;
|
||||||
onStageChange?: (stage: ConnectionStage) => void;
|
onStageChange?: (stage: ConnectionStage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +90,7 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
onError,
|
onError,
|
||||||
onFilesystem,
|
onFilesystem,
|
||||||
onDropFiles,
|
onDropFiles,
|
||||||
|
onDropUnavailable,
|
||||||
onStageChange,
|
onStageChange,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -776,8 +782,9 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
const handleDragEnter = useCallback(
|
const handleDragEnter = useCallback(
|
||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
if (!canDropFiles || !event.dataTransfer.types.includes("Files")) return;
|
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!canDropFiles) return;
|
||||||
dragDepthRef.current += 1;
|
dragDepthRef.current += 1;
|
||||||
setIsDraggingFiles(true);
|
setIsDraggingFiles(true);
|
||||||
},
|
},
|
||||||
@@ -791,15 +798,21 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(event: React.DragEvent) => {
|
(event: React.DragEvent) => {
|
||||||
if (!canDropFiles) return;
|
if (!hasDraggedFiles(event.dataTransfer.types)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
setIsDraggingFiles(false);
|
setIsDraggingFiles(false);
|
||||||
|
|
||||||
const files = Array.from(event.dataTransfer.files);
|
const files = Array.from(event.dataTransfer.files);
|
||||||
if (files.length > 0) onDropFiles?.(files);
|
const disposition = getFileDropDisposition(
|
||||||
|
event.dataTransfer.types,
|
||||||
|
files.length,
|
||||||
|
canDropFiles,
|
||||||
|
);
|
||||||
|
if (disposition === "upload") onDropFiles?.(files);
|
||||||
|
if (disposition === "reject") onDropUnavailable?.();
|
||||||
},
|
},
|
||||||
[canDropFiles, onDropFiles],
|
[canDropFiles, onDropFiles, onDropUnavailable],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -808,7 +821,9 @@ export const GuacamoleDisplay = forwardRef<
|
|||||||
className="absolute inset-0 overflow-hidden"
|
className="absolute inset-0 overflow-hidden"
|
||||||
style={{ backgroundColor: "var(--bg-base)" }}
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragOver={canDropFiles ? (e) => e.preventDefault() : undefined}
|
onDragOver={(event) => {
|
||||||
|
if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault();
|
||||||
|
}}
|
||||||
onDragLeave={canDropFiles ? handleDragLeave : undefined}
|
onDragLeave={canDropFiles ? handleDragLeave : undefined}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type FileDropDisposition = "ignore" | "reject" | "upload";
|
||||||
|
|
||||||
|
export function hasDraggedFiles(types: readonly string[]): boolean {
|
||||||
|
return types.includes("Files");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileDropDisposition(
|
||||||
|
types: readonly string[],
|
||||||
|
fileCount: number,
|
||||||
|
canUpload: boolean,
|
||||||
|
): FileDropDisposition {
|
||||||
|
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
|
||||||
|
return canUpload ? "upload" : "reject";
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { useXTerm } from "react-xtermjs";
|
import { useXTerm } from "react-xtermjs";
|
||||||
import { useTheme } from "@/components/theme-provider";
|
import { useTheme } from "@/components/theme-provider";
|
||||||
@@ -17,6 +17,14 @@ export function LocalTerminal({
|
|||||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const [isWindows, setIsWindows] = useState(false);
|
||||||
|
const [shell, setShell] = useState<"default" | "wsl">("default");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI?.getPlatform().then((platform) => {
|
||||||
|
setIsWindows(platform === "win32");
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fit = useCallback(() => {
|
const fit = useCallback(() => {
|
||||||
const fitAddon = fitAddonRef.current;
|
const fitAddon = fitAddonRef.current;
|
||||||
@@ -60,7 +68,7 @@ export function LocalTerminal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.electronAPI
|
window.electronAPI
|
||||||
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows })
|
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
|
||||||
.then(({ sessionId }) => {
|
.then(({ sessionId }) => {
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
window.electronAPI.closeLocalTerminal(sessionId);
|
window.electronAPI.closeLocalTerminal(sessionId);
|
||||||
@@ -100,11 +108,30 @@ export function LocalTerminal({
|
|||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
fitAddon.dispose();
|
fitAddon.dispose();
|
||||||
};
|
};
|
||||||
}, [fit, instanceId, terminal, xtermRef]);
|
}, [fit, instanceId, shell, terminal, xtermRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) requestAnimationFrame(fit);
|
if (isVisible) requestAnimationFrame(fit);
|
||||||
}, [fit, isVisible]);
|
}, [fit, isVisible]);
|
||||||
|
|
||||||
return <div ref={xtermRef} className="h-full w-full bg-background p-2" />;
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
|
{isWindows && (
|
||||||
|
<div className="flex justify-end border-b border-border px-2 py-1">
|
||||||
|
<select
|
||||||
|
aria-label="Local terminal shell"
|
||||||
|
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
|
||||||
|
value={shell}
|
||||||
|
onChange={(event) =>
|
||||||
|
setShell(event.target.value === "wsl" ? "wsl" : "default")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="default">PowerShell</option>
|
||||||
|
<option value="wsl">WSL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user