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:
Luke Gustafson
2026-08-22 19:47:40 -05:00
committed by GitHub
co-authored by Chetan Codebuff Maxime Bonillo ZacharyZcR alex-ctms Chetan Kumar ZacharyZcR dropafterfree
parent 566b908daf
commit 76fd9eedbf
165 changed files with 6778 additions and 2090 deletions
+76
View File
@@ -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[@]}"
+75
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
include:
- local: deploy/gitlab/.gitlab-ci.yml
+3
View File
@@ -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/
+4
View File
@@ -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
View File
@@ -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 -->
+6
View File
@@ -0,0 +1,6 @@
.git/
.github/
node_modules/
dist/
coverage/
*.tgz
+16
View File
@@ -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
+17
View File
@@ -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 }}
+45
View File
@@ -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 -}}
+148
View File
@@ -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 }}
+22
View File
@@ -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 }}
+35
View File
@@ -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 }}
+13
View File
@@ -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 }}
+21
View File
@@ -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 }}
+15
View File
@@ -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 }}
+19
View File
@@ -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 }}
+12
View File
@@ -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 -}}
+28
View File
@@ -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: []
+32
View File
@@ -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
+147
View File
@@ -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: []
+25
View File
@@ -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
+25
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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
+2
View File
@@ -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;
+1 -1
View File
@@ -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>",
+21
View File
@@ -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
View File
@@ -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") {
+2 -2
View File
@@ -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
View File
@@ -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",
+16 -2
View File
@@ -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;
} }
+27 -1
View File
@@ -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: {
+10
View File
@@ -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(),
+5
View File
@@ -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);
+159
View File
@@ -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,
}; };
} }
+13 -1
View File
@@ -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) {
+17 -2
View File
@@ -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
+21 -1
View File
@@ -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" });
+22 -6
View File
@@ -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);
});
});
}
+59 -550
View File
@@ -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,
};
}
+53 -9
View File
@@ -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",
}); });
+19 -3
View File
@@ -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);
} }
+13 -9
View File
@@ -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", () => {
+116 -2
View File
@@ -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
+103 -55
View File
@@ -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 }),
);
} }
} }
+14 -4
View File
@@ -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);
+18
View File
@@ -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);
});
});
+11 -1
View File
@@ -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),
});
}
+4
View File
@@ -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,
+76
View File
@@ -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);
}
+5 -1
View File
@@ -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>;
+1
View File
@@ -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;
+15
View File
@@ -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>> {
+10 -1
View File
@@ -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
+13 -1
View File
@@ -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";
+1
View File
@@ -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");
+54 -1
View File
@@ -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[];
}> { }> {
+103
View File
@@ -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,
}; };
} }
+196 -5
View File
@@ -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>
+106 -32
View File
@@ -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;
+15 -1
View File
@@ -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
+20 -5
View File
@@ -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