mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fd9eedbf | ||
|
|
566b908daf | ||
|
|
d6e8ee4784 | ||
|
|
8af4cbdec4 | ||
|
|
e17b21ff62 | ||
|
|
a15372a224 |
@@ -0,0 +1,76 @@
|
||||
name: Deploy Helm
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
namespace:
|
||||
description: "Kubernetes namespace"
|
||||
required: true
|
||||
default: termix
|
||||
release:
|
||||
description: "Helm release name"
|
||||
required: true
|
||||
default: termix
|
||||
values_file:
|
||||
description: "Values file to use"
|
||||
required: true
|
||||
default: charts/termix/values-gitops-example.yaml
|
||||
image_tag:
|
||||
description: "Image tag to deploy"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Configure kubeconfig
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
test -n "$KUBE_CONFIG"
|
||||
echo "$KUBE_CONFIG" | base64 -d > "$RUNNER_TEMP/kubeconfig"
|
||||
chmod 600 "$RUNNER_TEMP/kubeconfig"
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint charts/termix
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
KUBECONFIG: ${{ runner.temp }}/kubeconfig
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
RELEASE_NAME: ${{ inputs.release }}
|
||||
TARGET_NAMESPACE: ${{ inputs.namespace }}
|
||||
VALUES_FILE: ${{ inputs.values_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$RELEASE_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||
[[ "$TARGET_NAMESPACE" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]
|
||||
case "$VALUES_FILE" in
|
||||
charts/termix/*.yaml) ;;
|
||||
*) echo "values_file must be a YAML file under charts/termix" >&2; exit 1 ;;
|
||||
esac
|
||||
test -f "$VALUES_FILE"
|
||||
ARGS=()
|
||||
if [ -n "$IMAGE_TAG" ]; then
|
||||
ARGS+=(--set "image.tag=$IMAGE_TAG")
|
||||
fi
|
||||
helm upgrade --install "$RELEASE_NAME" charts/termix \
|
||||
--namespace "$TARGET_NAMESPACE" \
|
||||
--create-namespace \
|
||||
--values "$VALUES_FILE" \
|
||||
--atomic \
|
||||
--timeout 10m \
|
||||
"${ARGS[@]}"
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Helm
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "charts/**"
|
||||
- ".github/workflows/helm.yml"
|
||||
- "deploy/**"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "charts/**"
|
||||
- ".github/workflows/helm.yml"
|
||||
- "deploy/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish chart to GHCR OCI registry"
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Lint chart
|
||||
run: helm lint charts/termix
|
||||
|
||||
- name: Render default chart
|
||||
run: helm template termix charts/termix --namespace termix
|
||||
|
||||
- name: Render GitOps example
|
||||
run: helm template termix charts/termix --values charts/termix/values-gitops-example.yaml --namespace termix
|
||||
|
||||
- name: Render Traefik example
|
||||
run: helm template termix charts/termix --values charts/termix/values-traefik.yaml --namespace termix
|
||||
|
||||
publish:
|
||||
needs: lint
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@v5
|
||||
with:
|
||||
version: v3.15.4
|
||||
|
||||
- name: Log in to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Package chart
|
||||
run: helm package charts/termix --destination .helm-packages
|
||||
|
||||
- name: Push chart
|
||||
run: |
|
||||
OWNER="$(echo "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
|
||||
helm push .helm-packages/termix-*.tgz "oci://ghcr.io/$OWNER/charts"
|
||||
@@ -443,13 +443,21 @@ jobs:
|
||||
|
||||
- name: Create docs release branch
|
||||
working-directory: docs-repo
|
||||
run: git checkout -B "dev-${{ needs.prep.outputs.version }}"
|
||||
run: |
|
||||
BRANCH="dev-${{ needs.prep.outputs.version }}"
|
||||
if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
|
||||
echo "Reusing existing docs branch $BRANCH."
|
||||
git checkout -B "$BRANCH" "origin/$BRANCH"
|
||||
else
|
||||
git checkout -B "$BRANCH"
|
||||
fi
|
||||
|
||||
- name: Overwrite OpenAPI spec and regenerate API docs
|
||||
working-directory: docs-repo
|
||||
run: |
|
||||
cp ../termix/openapi.json static/openapi.json
|
||||
npm ci
|
||||
npm run docusaurus clean-api-docs termix
|
||||
npm run docusaurus gen-api-docs termix
|
||||
|
||||
- name: Commit and push docs branch
|
||||
@@ -471,7 +479,7 @@ jobs:
|
||||
|
||||
git add -A
|
||||
git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
|
||||
git push --force origin "dev-${{ needs.prep.outputs.version }}"
|
||||
git push origin "dev-${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Open and squash-merge docs PR
|
||||
if: ${{ inputs.mode != 'Dry run' }}
|
||||
@@ -534,7 +542,7 @@ jobs:
|
||||
docs,
|
||||
publish-youtube,
|
||||
]
|
||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' }}
|
||||
if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }}
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
include:
|
||||
- local: deploy/gitlab/.gitlab-ci.yml
|
||||
@@ -20,3 +20,6 @@ openapi.json
|
||||
|
||||
# Generated by drizzle-kit; formatting is the tool's own
|
||||
drizzle/
|
||||
|
||||
# Helm templates contain Go template syntax, not plain YAML
|
||||
charts/*/templates/
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "2.6.1"
|
||||
sha256 "716fcbd4ad4aef3dc19f2eb84cee8c46179860eaac180c98cdcadbceca0dacc6"
|
||||
version "2.7.0"
|
||||
sha256 "8cdae5cf5ce2786e35a1a676dec51974cfca318a5595bc743981a4c373515059"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -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.
|
||||
|
||||
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):
|
||||
|
||||
```yaml
|
||||
|
||||
+40
-105
@@ -1,6 +1,6 @@
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -12,114 +12,49 @@ https://youtu.be/lngaePO96tM
|
||||
|
||||
<!-- UPDATE_LOG -->
|
||||
|
||||
- Added completely optional and disabled/removed by default Termix AI, an assistant that can work with your hosts and terminals
|
||||
- This feature was added based off a 60% (yes) to 40% (no) Discord vote.
|
||||
- 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.
|
||||
- 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.
|
||||
- Both an admin and each user must turn it on before it does anything, and it stays off after upgrading.
|
||||
- Added automations with events, channels, and steps
|
||||
- Added a fleet system with snippets, packages, files, and inventory
|
||||
- Added workspaces to save and restore your tab layout
|
||||
- Added subhosts so hosts can be organized under a parent host
|
||||
- Added Proxmox metrics integration
|
||||
- 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
|
||||
- Added the ability to clone an existing credential
|
||||
- Added a WSL option for the local terminal
|
||||
- Added Helm charts and a GitOps deployment setup
|
||||
- Added an editable path bar to the file manager
|
||||
- Added a progress bar for file downloads in the file manager
|
||||
- Added an identity file option so agent authentication stops after the right key
|
||||
- Added an environment variable to turn on silent OIDC login
|
||||
- Added editable model settings for AI providers
|
||||
- Improved tmux monitor performance when aggregating sessions
|
||||
- Improved Linux packaging with standard icon sizes
|
||||
|
||||
<!-- /UPDATE_LOG -->
|
||||
|
||||
<!-- BUG_FIXES -->
|
||||
|
||||
- Periodic SSH terminal stalls caused by SQLite telemetry writes
|
||||
- Missing OPKSSH binary breaking installs without internet access
|
||||
- Session recording writes slowing down terminals
|
||||
- SGR mouse tracking escape codes printing as text
|
||||
- Terminal display distortion with special characters
|
||||
- Windows Ctrl+W not closing the active tab
|
||||
- Tray Quit not terminating the desktop app
|
||||
- Mobile terminal scrollback not matching xterm wheel behavior
|
||||
- tmux breaking on UTF-8 paths
|
||||
- Sudo password auto-fill not persisting
|
||||
- SSH and sudo passwords not being saved or auto-filled
|
||||
- Switching SSH authentication away from Vault failing
|
||||
- Host edits being discarded without a warning
|
||||
- Quick-created credentials not being selected
|
||||
- Saved RDP connection settings not being preserved
|
||||
- RDP domain credentials not being prompted for
|
||||
- Windows key mapping in remote desktop sessions
|
||||
- VNC failing to connect to macOS screen sharing
|
||||
- Mouse input breaking on touch-capable devices in RDP and VNC
|
||||
- Docker runtime selection not persisting, plus Docker manager UI issues
|
||||
- Desktop Docker console WebSocket not being authenticated
|
||||
- Folders intermittently disappearing from duplicate requests
|
||||
- Folder deletion not refreshing the host list
|
||||
- Proxmox guest identity being lost on edit
|
||||
- Long host names shifting dashboard metrics
|
||||
- Host list rows resizing unexpectedly
|
||||
- Metrics collection all firing at once on startup
|
||||
- Session activity writes hitting the database too often
|
||||
- Reachable and available hosts being treated the same
|
||||
- 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
|
||||
- Passkey sign in not showing up on the login screen
|
||||
- Connections through jump hosts failing
|
||||
- Jump hosts and remote desktop hosts not resolving after a sync
|
||||
- Malformed websocket messages crashing the server
|
||||
- Encrypted file manager keys not prompting for a passphrase
|
||||
- SSH agent forwarding not working with the in-memory agent
|
||||
- Two factor prompts rejecting codes longer than six digits
|
||||
- OIDC lockout with no way to recover from environment settings
|
||||
- Tailscale requests failing on some setups
|
||||
- Terminal clipboard shortcuts not working on non-QWERTY layouts
|
||||
- Rapid mobile terminal input being sent one keystroke at a time
|
||||
- HTTPS not being able to share the configured port
|
||||
- Portable imports failing on remote databases
|
||||
- Host status not showing when metrics collection is off
|
||||
- Proxmox guest credential usernames being wrong
|
||||
- File drops not working for RDP in the browser
|
||||
- Duplicate Docker HTTPS listener on startup
|
||||
- Remote sync server probe ignoring the certificate setting
|
||||
- Runtime SSL settings not being preserved
|
||||
- Automation notifications missing host details
|
||||
- Connection screens crashing outside the connection log provider
|
||||
- better-sqlite3 failing in Docker on some platforms
|
||||
- Host action rows shifting at large font sizes
|
||||
- Sidebar height jumping when hosts or credentials have tags
|
||||
- Gaps between host rows in the sidebar list
|
||||
- Rounded corners on the host list search bar
|
||||
- Image storage settings text wrapping to one word per line
|
||||
- Unclear wording on the click-to-expand host setting
|
||||
- Dragging a folder into the file manager failing to upload
|
||||
|
||||
<!-- /BUG_FIXES -->
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.git/
|
||||
.github/
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tgz
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v2
|
||||
name: termix
|
||||
description: Self-hosted SSH and remote desktop management for Kubernetes.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: 2.7.0
|
||||
home: https://github.com/Termix-SSH/Termix
|
||||
sources:
|
||||
- https://github.com/Termix-SSH/Termix
|
||||
maintainers:
|
||||
- name: Termix maintainers
|
||||
keywords:
|
||||
- ssh
|
||||
- remote-desktop
|
||||
- guacamole
|
||||
- terminal
|
||||
@@ -0,0 +1,17 @@
|
||||
Termix has been installed.
|
||||
|
||||
Service:
|
||||
{{ include "termix.fullname" . }}:{{ .Values.service.port }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Ingress hosts:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- {{ .host }}
|
||||
{{- end }}
|
||||
{{- else if .Values.traefik.ingressRoute.enabled }}
|
||||
Traefik IngressRoute:
|
||||
https://{{ .Values.traefik.ingressRoute.host }}{{ .Values.traefik.ingressRoute.pathPrefix }}
|
||||
{{- else }}
|
||||
Port-forward for local testing:
|
||||
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "termix.fullname" . }} 8080:{{ .Values.service.port }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{- define "termix.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.labels" -}}
|
||||
helm.sh/chart: {{ include "termix.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "termix.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{- default (include "termix.fullname" .) .Values.serviceAccount.name -}}
|
||||
{{- else -}}
|
||||
{{- default "default" .Values.serviceAccount.name -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "termix.secretName" -}}
|
||||
{{- default (printf "%s-secret" (include "termix.fullname" .)) .Values.secrets.name -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,148 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
strategy:
|
||||
{{- toYaml .Values.strategy | nindent 4 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "termix.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "termix.serviceAccountName" . }}
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: termix
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
{{- range $key, $value := .Values.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.guacd.enabled }}
|
||||
- name: GUACD_HOST
|
||||
value: "127.0.0.1"
|
||||
{{- else if .Values.guacd.host }}
|
||||
- name: GUACD_HOST
|
||||
value: {{ .Values.guacd.host | quote }}
|
||||
{{- end }}
|
||||
- name: GUACD_TUNNEL_HOST
|
||||
value: {{ include "termix.fullname" . | quote }}
|
||||
{{- if ne .Values.database.dialect "sqlite" }}
|
||||
- name: DATABASE_DIALECT
|
||||
value: {{ .Values.database.dialect | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.database.existingSecret.name }}
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.database.existingSecret.name }}
|
||||
key: {{ .Values.database.existingSecret.urlKey }}
|
||||
{{- end }}
|
||||
{{- if .Values.secrets.create }}
|
||||
{{- range $key, $value := .Values.secrets.data }}
|
||||
{{- if $value }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "termix.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.liveness.enabled }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
|
||||
{{- end }}
|
||||
{{- if .Values.probes.readiness.enabled }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
|
||||
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
|
||||
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
|
||||
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
{{- if .Values.guacd.enabled }}
|
||||
- name: guacd
|
||||
image: "{{ .Values.guacd.image.repository }}:{{ .Values.guacd.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.guacd.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.guacd.securityContext | nindent 12 }}
|
||||
ports:
|
||||
- name: guacd
|
||||
containerPort: {{ .Values.guacd.service.port }}
|
||||
protocol: TCP
|
||||
resources:
|
||||
{{- toYaml .Values.guacd.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /termix-data
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-data" (include "termix.fullname" .)) .Values.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{- if .Values.autoscaling.enabled -}}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "termix.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "termix.fullname" $ }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.networkPolicy.enabled -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
{{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
|
||||
egress:
|
||||
{{- toYaml .Values.networkPolicy.egress | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.podDisruptionBudget.enabled -}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
spec:
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "termix.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}-data
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.persistence.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.size | quote }}
|
||||
{{- if .Values.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if .Values.secrets.create -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "termix.secretName" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{- range $key, $value := .Values.secrets.data }}
|
||||
{{- if $value }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "termix.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "termix.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{- if .Values.traefik.ingressRoute.enabled -}}
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: {{ include "termix.fullname" . }}
|
||||
labels:
|
||||
{{- include "termix.labels" . | nindent 4 }}
|
||||
{{- with .Values.traefik.ingressRoute.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
entryPoints:
|
||||
{{- toYaml .Values.traefik.ingressRoute.entryPoints | nindent 4 }}
|
||||
routes:
|
||||
- match: Host(`{{ .Values.traefik.ingressRoute.host }}`) && PathPrefix(`{{ .Values.traefik.ingressRoute.pathPrefix }}`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: {{ include "termix.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- with .Values.traefik.ingressRoute.middlewares }}
|
||||
middlewares:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if .Values.traefik.ingressRoute.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.traefik.ingressRoute.tls.secretName }}
|
||||
secretName: {{ .Values.traefik.ingressRoute.tls.secretName }}
|
||||
{{- end }}
|
||||
{{- if .Values.traefik.ingressRoute.tls.certResolver }}
|
||||
certResolver: {{ .Values.traefik.ingressRoute.tls.certResolver }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{- if and .Values.ingress.enabled .Values.traefik.ingressRoute.enabled -}}
|
||||
{{- fail "ingress and traefik.ingressRoute cannot both be enabled" -}}
|
||||
{{- end -}}
|
||||
{{- if and (eq .Values.database.dialect "sqlite") (or (gt (int .Values.replicaCount) 1) .Values.autoscaling.enabled) -}}
|
||||
{{- fail "SQLite supports only one replica; use Postgres or MySQL before enabling replicas or autoscaling" -}}
|
||||
{{- end -}}
|
||||
{{- if and (not .Values.guacd.enabled) (not .Values.guacd.host) -}}
|
||||
{{- fail "guacd.host is required when the bundled guacd sidecar is disabled" -}}
|
||||
{{- end -}}
|
||||
{{- if and .Values.database.existingSecret.name .Values.secrets.create .Values.secrets.data.DATABASE_URL -}}
|
||||
{{- fail "DATABASE_URL must come from either database.existingSecret or secrets.data, not both" -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,28 @@
|
||||
image:
|
||||
repository: ghcr.io/termix-ssh/termix
|
||||
tag: "2.7.0"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: termix.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: termix-tls
|
||||
hosts:
|
||||
- termix.example.com
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
|
||||
secrets:
|
||||
create: false
|
||||
|
||||
database:
|
||||
dialect: sqlite
|
||||
|
||||
extraEnvFrom: []
|
||||
@@ -0,0 +1,32 @@
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
traefik:
|
||||
ingressRoute:
|
||||
enabled: true
|
||||
entryPoints:
|
||||
- websecure
|
||||
host: termix.example.com
|
||||
pathPrefix: /
|
||||
tls:
|
||||
enabled: true
|
||||
certResolver: letsencrypt
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
memory: 2Gi
|
||||
|
||||
guacd:
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
@@ -0,0 +1,147 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/termix-ssh/termix
|
||||
pullPolicy: IfNotPresent
|
||||
tag: ""
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
automount: false
|
||||
annotations: {}
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
securityContext: {}
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
annotations: {}
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
hosts:
|
||||
- host: termix.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
|
||||
traefik:
|
||||
ingressRoute:
|
||||
enabled: false
|
||||
entryPoints:
|
||||
- websecure
|
||||
host: termix.example.com
|
||||
pathPrefix: /
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: ""
|
||||
certResolver: ""
|
||||
middlewares: []
|
||||
annotations: {}
|
||||
|
||||
resources: {}
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 3
|
||||
targetCPUUtilizationPercentage: 80
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
annotations: {}
|
||||
existingClaim: ""
|
||||
|
||||
env:
|
||||
PORT: "8080"
|
||||
DATA_DIR: /app/data
|
||||
NODE_ENV: production
|
||||
GUACD_RECORDING_PATH: /termix-data/session_recordings/guacamole
|
||||
GUACD_RECORDING_BACKEND_PATH: /app/data/session_recordings/guacamole
|
||||
|
||||
extraEnv: []
|
||||
extraEnvFrom: []
|
||||
|
||||
secrets:
|
||||
create: false
|
||||
name: ""
|
||||
data:
|
||||
JWT_SECRET: ""
|
||||
DATABASE_URL: ""
|
||||
GUACAMOLE_ENCRYPTION_KEY: ""
|
||||
|
||||
database:
|
||||
dialect: sqlite
|
||||
existingSecret:
|
||||
name: ""
|
||||
urlKey: DATABASE_URL
|
||||
|
||||
guacd:
|
||||
enabled: true
|
||||
host: ""
|
||||
image:
|
||||
repository: guacamole/guacd
|
||||
tag: 1.6.0
|
||||
pullPolicy: IfNotPresent
|
||||
resources: {}
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
service:
|
||||
port: 4822
|
||||
annotations: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
strategy:
|
||||
type: Recreate
|
||||
|
||||
probes:
|
||||
liveness:
|
||||
enabled: true
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 10
|
||||
failureThreshold: 3
|
||||
readiness:
|
||||
enabled: true
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
ingress: []
|
||||
egress: []
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: termix-traefik
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||
targetRevision: main
|
||||
path: charts/termix
|
||||
helm:
|
||||
releaseName: termix
|
||||
valueFiles:
|
||||
- values-traefik.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: termix
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- PruneLast=true
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: termix
|
||||
namespace: argocd
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/Termix-SSH/Termix.git
|
||||
targetRevision: main
|
||||
path: charts/termix
|
||||
helm:
|
||||
releaseName: termix
|
||||
valueFiles:
|
||||
- values-gitops-example.yaml
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: termix
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
- PruneLast=true
|
||||
@@ -0,0 +1,70 @@
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- package
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
|
||||
HELM_EXPERIMENTAL_OCI: "1"
|
||||
|
||||
helm-lint:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: test
|
||||
script:
|
||||
- helm lint charts/termix
|
||||
- helm template termix charts/termix --values charts/termix/values-gitops-example.yaml >/tmp/termix.yaml
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
docker-build-push:
|
||||
image: docker:27
|
||||
stage: build
|
||||
services:
|
||||
- docker:27-dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- docker build -f docker/Dockerfile -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
|
||||
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
|
||||
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
helm-package-push:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: package
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | helm registry login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- helm dependency update charts/termix
|
||||
- helm package charts/termix --destination .helm-packages
|
||||
- helm push .helm-packages/termix-*.tgz "oci://$CI_REGISTRY_IMAGE/charts"
|
||||
artifacts:
|
||||
paths:
|
||||
- .helm-packages/
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
deploy:
|
||||
image:
|
||||
name: alpine/helm:3.15.4
|
||||
entrypoint: [""]
|
||||
stage: deploy
|
||||
environment:
|
||||
name: production
|
||||
before_script:
|
||||
- echo "$KUBE_CONFIG" | base64 -d > kubeconfig
|
||||
- chmod 600 kubeconfig
|
||||
script:
|
||||
- helm upgrade --install termix charts/termix --namespace termix --create-namespace --kubeconfig kubeconfig --set image.repository="$CI_REGISTRY_IMAGE" --set image.tag="$IMAGE_TAG" --values charts/termix/values-gitops-example.yaml --atomic --timeout 10m
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
when: manual
|
||||
+7
-2
@@ -31,7 +31,9 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
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
|
||||
|
||||
@@ -67,7 +69,10 @@ COPY scripts/patch-guacamole-lite.cjs ./scripts/
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
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
|
||||
|
||||
# Stage 6: Final optimized image
|
||||
|
||||
@@ -23,6 +23,18 @@ if [ "$(id -u)" = "0" ]; then
|
||||
fi
|
||||
|
||||
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
|
||||
echo "Loading persisted SSL settings from $DATA_DIR/.env"
|
||||
set -a
|
||||
@@ -30,11 +42,18 @@ if [ -f "$DATA_DIR/.env" ]; then
|
||||
set +a
|
||||
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 ENABLE_SSL=${ENABLE_SSL:-false}
|
||||
export SSL_PORT=${SSL_PORT:-8443}
|
||||
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 TERMIX_SSL_TERMINATED_BY_NGINX=true
|
||||
|
||||
echo "Configuring web UI to run on port: $PORT"
|
||||
|
||||
@@ -49,6 +68,11 @@ fi
|
||||
mkdir -p /tmp/nginx
|
||||
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
|
||||
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
|
||||
|
||||
|
||||
@@ -69,12 +69,14 @@ http {
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# BEGIN HTTP_REDIRECT_SERVER
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name _;
|
||||
|
||||
return 301 https://$host:${SSL_PORT}$request_uri;
|
||||
}
|
||||
# END HTTP_REDIRECT_SERVER
|
||||
|
||||
server {
|
||||
listen ${SSL_PORT} ssl;
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"arch": ["x64", "arm64", "armv7l"]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.png",
|
||||
"icon": "public/icons",
|
||||
"category": "Development",
|
||||
"executableName": "termix",
|
||||
"maintainer": "Termix <mail@termix.site>",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function resolveLocalShell(platform, requestedShell, env = process.env) {
|
||||
if (platform === "win32") {
|
||||
if (requestedShell === "wsl") {
|
||||
return { file: "wsl.exe", args: [] };
|
||||
}
|
||||
return {
|
||||
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
||||
args: ["-NoLogo"],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
file:
|
||||
env.TERMIX_LOCAL_SHELL ||
|
||||
env.SHELL ||
|
||||
(platform === "darwin" ? "/bin/zsh" : "/bin/bash"),
|
||||
args: ["-l"],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { resolveLocalShell };
|
||||
+16
-20
@@ -30,25 +30,10 @@ const { launchNativeRdp } = require("./native-rdp.cjs");
|
||||
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
||||
const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
const { resolveLocalShell } = require("./local-shell.cjs");
|
||||
|
||||
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) {
|
||||
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
||||
return null;
|
||||
@@ -537,7 +522,11 @@ function httpFetch(url, options = {}) {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
...(isHttps ? getTlsVerificationOptions(url) : {}),
|
||||
...(isHttps
|
||||
? options.allowInvalidCertificate
|
||||
? { rejectUnauthorized: false }
|
||||
: getTlsVerificationOptions(url)
|
||||
: {}),
|
||||
};
|
||||
|
||||
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 rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
||||
const sessionId = crypto.randomUUID();
|
||||
const shellConfig = localShell();
|
||||
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
||||
const child = pty.spawn(shellConfig.file, shellConfig.args, {
|
||||
name: "xterm-256color",
|
||||
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 {
|
||||
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
||||
const healthUrl = `${normalizedServerUrl}/health`;
|
||||
@@ -3154,6 +3147,7 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
const response = await httpFetch(healthUrl, {
|
||||
method: "GET",
|
||||
timeout: 10000,
|
||||
allowInvalidCertificate,
|
||||
});
|
||||
|
||||
const data = await response.text();
|
||||
@@ -3207,7 +3201,9 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("test-server-connection", testServerConnection);
|
||||
|
||||
function createMenu() {
|
||||
if (process.platform === "darwin") {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.116.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -30,8 +30,11 @@ const patches = [
|
||||
file: "xterm.mjs",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},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=""',
|
||||
@@ -46,8 +49,11 @@ const patches = [
|
||||
"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,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,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(){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)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
@@ -55,8 +61,11 @@ const patches = [
|
||||
file: "xterm.js",
|
||||
replacements: [
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
|
||||
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
|
||||
[
|
||||
'this._compositionPosition={start:0,end:0},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=""',
|
||||
@@ -71,8 +80,11 @@ const patches = [
|
||||
"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;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,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(){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)) {
|
||||
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(
|
||||
`[patch-xterm-android-ime] Expected source not found in ${file}`,
|
||||
);
|
||||
}
|
||||
source = source.replace(original, patched);
|
||||
source = source.replace(matched, patched);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../database/repositories/factory.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { resolveHostById } from "../hosts/host-resolver.js";
|
||||
import { executeStep } from "./actions/index.js";
|
||||
import type { StepExecutionContext, StepResult } from "./actions/types.js";
|
||||
import { compare } from "./conditions.js";
|
||||
@@ -175,8 +176,33 @@ export class AutomationEngine {
|
||||
|
||||
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 = {
|
||||
trigger: request.triggerContext ?? {},
|
||||
host,
|
||||
trigger,
|
||||
steps: {},
|
||||
vars: {},
|
||||
run: {
|
||||
|
||||
@@ -88,6 +88,16 @@ async function sendWebhook(
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
severity: notification.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -2052,7 +2052,12 @@ if (sslConfig.enabled) {
|
||||
ssl_port: sslConfig.port,
|
||||
backend_http_port: HTTP_PORT,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
sslConfig.enabled &&
|
||||
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
|
||||
) {
|
||||
try {
|
||||
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
|
||||
* constraints have to stand down for the duration.
|
||||
*
|
||||
* **This has no equivalent on Postgres or MySQL here.** Postgres needs
|
||||
* superuser to disable triggers, and MySQL's `SET FOREIGN_KEY_CHECKS = 0` is
|
||||
* per-connection, which a pool does not guarantee. Rather than run the import
|
||||
* with constraints enforced and have it fail partway through — leaving a
|
||||
* half-restored database — it refuses with a message that says why.
|
||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
||||
* writes through repositories and handles individual row failures, so it must
|
||||
* still be allowed to run there; only SQLite needs this connection-local
|
||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
||||
*/
|
||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dialect = resolveDatabaseDialect();
|
||||
if (!needsExplicitPersist(dialect)) {
|
||||
throw new Error(
|
||||
`Importing a backup is only supported on SQLite; this deployment uses ${dialect}. ` +
|
||||
`Restore into the database directly with its own tooling instead.`,
|
||||
);
|
||||
return operation();
|
||||
}
|
||||
|
||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||
|
||||
@@ -388,6 +388,165 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/duplicate:
|
||||
* post:
|
||||
* summary: Duplicate a credential
|
||||
* description: Creates a new credential from an existing one, optionally overriding fields (e.g. password), leaving the original credential untouched.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* key:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: New credential created from the duplicate.
|
||||
* 400:
|
||||
* description: Invalid request.
|
||||
* 404:
|
||||
* description: Credential not found.
|
||||
* 500:
|
||||
* description: Failed to duplicate credential.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/duplicate",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const { name, username, password, key, keyPassword, certPublicKey } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for credential duplicate");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
const credentialId = parseInt(id);
|
||||
|
||||
try {
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const source = await credentialRepository.findDecryptedByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (!source) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const authType = source.authType;
|
||||
const plainPassword =
|
||||
password !== undefined ? password || null : source.password;
|
||||
const plainKey = key !== undefined ? key || null : source.key;
|
||||
const plainKeyPassword =
|
||||
keyPassword !== undefined ? keyPassword || null : source.keyPassword;
|
||||
|
||||
let keyInfo = null;
|
||||
if (authType === "key" && plainKey) {
|
||||
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
|
||||
if (!keyInfo.success) {
|
||||
return res.status(400).json({
|
||||
error: keyInfo.error
|
||||
? `Invalid SSH key: ${keyInfo.error}`
|
||||
: "Unrecognized SSH key format. Use an OpenSSH, PEM, or PuTTY PPK v2 RSA/DSA private key.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const credentialData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: source.description,
|
||||
folder: source.folder,
|
||||
tags: source.tags,
|
||||
authType,
|
||||
username:
|
||||
username !== undefined ? username?.trim() || null : source.username,
|
||||
password: authType === "password" ? plainPassword : null,
|
||||
key: authType === "key" ? plainKey : null,
|
||||
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
|
||||
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
|
||||
keyPassword: authType === "key" ? plainKeyPassword : null,
|
||||
keyType: source.keyType,
|
||||
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
|
||||
certPublicKey:
|
||||
authType === "key"
|
||||
? certPublicKey !== undefined
|
||||
? certPublicKey?.trim() || null
|
||||
: source.certPublicKey
|
||||
: null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credentialData,
|
||||
);
|
||||
|
||||
const { ipAddress: dupIp, userAgent: dupUa } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "duplicate_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: String(created.id),
|
||||
resourceName: name,
|
||||
ipAddress: dupIp,
|
||||
userAgent: dupUa,
|
||||
success: true,
|
||||
});
|
||||
|
||||
authLogger.success(
|
||||
`SSH credential duplicated: ${name} (from ${credentialId}) by user ${userId}`,
|
||||
{
|
||||
operation: "credential_duplicate_success",
|
||||
userId,
|
||||
sourceCredentialId: credentialId,
|
||||
credentialId: created.id,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(201).json(formatCredentialOutput(created));
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to duplicate credential", err, {
|
||||
operation: "credential_duplicate",
|
||||
userId,
|
||||
credentialId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: getErrorMessage(err, "Failed to duplicate credential"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}:
|
||||
|
||||
@@ -32,7 +32,7 @@ export function resolveProxmoxImportAuth(
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: 1,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -689,6 +689,12 @@ async function syncProxmoxHost(
|
||||
? existing.connectionType
|
||||
: null;
|
||||
const connectionType = existingConnectionType ?? guest.connectionType;
|
||||
const usesImportCredential =
|
||||
connectionType === "ssh" && importAuth.authType === "credential";
|
||||
const existingUsesImportCredential =
|
||||
usesImportCredential &&
|
||||
existing?.authType === "credential" &&
|
||||
existing?.credentialId === importAuth.credentialId;
|
||||
const port =
|
||||
typeof existing?.port === "number"
|
||||
? existing.port
|
||||
@@ -696,11 +702,13 @@ async function syncProxmoxHost(
|
||||
? 3389
|
||||
: 22;
|
||||
const username =
|
||||
typeof existing?.username === "string" && existing.username
|
||||
? existing.username
|
||||
: connectionType === "rdp"
|
||||
? ""
|
||||
: "root";
|
||||
usesImportCredential && (!existing || existingUsesImportCredential)
|
||||
? ""
|
||||
: typeof existing?.username === "string" && existing.username
|
||||
? existing.username
|
||||
: connectionType === "rdp"
|
||||
? ""
|
||||
: "root";
|
||||
const update: Record<string, unknown> = {
|
||||
name: guest.name,
|
||||
ip: guest.ip || existing?.ip || "0.0.0.0",
|
||||
@@ -714,6 +722,10 @@ async function syncProxmoxHost(
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (existingUsesImportCredential) {
|
||||
update.credentialId = importAuth.credentialId;
|
||||
update.overrideCredentialUsername = false;
|
||||
}
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
existing.id as number,
|
||||
|
||||
@@ -7,7 +7,10 @@ import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||
import { getOIDCConfigFromEnv } from "./user-oidc-utils.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} from "./user-oidc-utils.js";
|
||||
import {
|
||||
decryptSsoConfigSecrets,
|
||||
encryptSsoConfigSecrets,
|
||||
@@ -18,6 +21,19 @@ function isOidcLike(type: SSOProviderType): boolean {
|
||||
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();
|
||||
|
||||
/**
|
||||
@@ -95,13 +111,19 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return res.json([
|
||||
{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 },
|
||||
]);
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// If no DB providers exist, synthesize one from env vars so SSO login
|
||||
// remains available when configured purely via environment variables.
|
||||
if (providers.length === 0) {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
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(", ")}`,
|
||||
});
|
||||
}
|
||||
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 (
|
||||
(type === "github" || type === "google") &&
|
||||
(!c.client_id || !c.client_secret)
|
||||
@@ -353,6 +381,16 @@ export function registerSSOProviderRoutes(router: Router): void {
|
||||
),
|
||||
...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(
|
||||
mergedConfig,
|
||||
userId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type Router as ExpressRouter,
|
||||
} from "express";
|
||||
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";
|
||||
|
||||
interface TailscaleDevice {
|
||||
@@ -74,12 +74,11 @@ export function registerTailscaleRoutes(
|
||||
);
|
||||
|
||||
const url = `${apiBase}/tailnet/-/devices?fields=all`;
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithProxy(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"User-Agent": "Termix/1.0",
|
||||
},
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -105,6 +105,10 @@ export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||
* whether they emit bare names (`devops-interns`) or full paths
|
||||
@@ -438,6 +442,11 @@ export async function loadProviderConfig(
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
if (providerId != null) {
|
||||
try {
|
||||
const row =
|
||||
@@ -485,7 +494,6 @@ export async function loadProviderConfig(
|
||||
}
|
||||
|
||||
// Fallback: env vars
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
@@ -542,6 +550,14 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
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 {
|
||||
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
||||
@@ -569,7 +585,6 @@ export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
|
||||
@@ -137,6 +137,12 @@ function isPasswordResetAllowed(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function getOidcSilentLoginDefaultFromEnv(): boolean | undefined {
|
||||
const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT;
|
||||
if (envVal === undefined) return undefined;
|
||||
return envVal.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function isNativeAppRequest(req: Request): boolean {
|
||||
return (
|
||||
(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:
|
||||
* get:
|
||||
* 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:
|
||||
* - Users
|
||||
* responses:
|
||||
@@ -2471,11 +2477,17 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.get("/oidc-silent-login-default", async (_req, res) => {
|
||||
try {
|
||||
const envVal = getOidcSilentLoginDefaultFromEnv();
|
||||
if (envVal !== undefined) {
|
||||
res.json({ enabled: envVal, locked: true });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"oidc_silent_login_default",
|
||||
false,
|
||||
),
|
||||
locked: false,
|
||||
});
|
||||
} catch (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.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 409:
|
||||
* description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
@@ -2520,6 +2534,12 @@ router.patch(
|
||||
if (!user) {
|
||||
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;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
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 { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
@@ -42,8 +42,11 @@ import {
|
||||
} from "./transfer-engine.js";
|
||||
import { registerFileContentRoutes } from "./content-routes.js";
|
||||
import { createConnectionLog } from "../connection-log.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js";
|
||||
import {
|
||||
isPrivateKeyPassphraseError,
|
||||
preparePrivateKeyForSSH2,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import {
|
||||
ChannelOpenSerializer,
|
||||
execChannel,
|
||||
@@ -840,7 +843,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||
@@ -909,7 +912,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||
@@ -1049,6 +1052,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
|
||||
const parsedKey = ssh2Pkg.utils.parseKey(
|
||||
config.privateKey as Buffer,
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
if (parsedKey instanceof Error) throw parsedKey;
|
||||
|
||||
if (resolvedCredentials.keyPassword)
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
|
||||
@@ -1071,6 +1080,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
} catch (keyError) {
|
||||
if (isPrivateKeyPassphraseError(keyError)) {
|
||||
return res.json({ status: "passphrase_required", connectionLogs });
|
||||
}
|
||||
|
||||
fileLogger.error("SSH key format error for file manager", {
|
||||
operation: "file_connect",
|
||||
sessionId,
|
||||
@@ -1832,7 +1845,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export const SFTP_OPEN_READ = 0x00000001;
|
||||
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||
|
||||
export function promisifySftpStat(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpUnlink(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpRmdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpMkdir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.mkdir(path, { mode }, (err) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpChmod(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.chmod(path, mode, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpReaddir(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.readdir(path, (err, list) => {
|
||||
if (err) reject(err);
|
||||
else resolve(list);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpOpen(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
flags: number,
|
||||
mode: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.open(path, flags, mode, (err, handle) => {
|
||||
if (err) reject(err);
|
||||
else resolve(handle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpClose(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.close(handle, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function promisifySftpFstat(
|
||||
sftp: SFTPWrapper,
|
||||
handle: Buffer,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.fstat(handle, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { randomUUID } from "crypto";
|
||||
import { networkInterfaces } from "os";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
basename,
|
||||
buildPathFromSegments,
|
||||
dirname,
|
||||
getWorkingDir,
|
||||
inferPlatformFromPath,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
normalizeSftpPath,
|
||||
pathsOverlap,
|
||||
sftpPathToLocalPath,
|
||||
splitPathSegments,
|
||||
type TransferPlatform,
|
||||
} from "../transfer-paths.js";
|
||||
import {
|
||||
@@ -26,6 +23,60 @@ import {
|
||||
type TransferScanSummary,
|
||||
} from "./transfer-routing.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 {
|
||||
buildDirectProbeCommand,
|
||||
buildDirectRsyncCommand,
|
||||
@@ -107,31 +158,11 @@ export type TransferStatus =
|
||||
"running" | "success" | "partial" | "error" | "cancelled";
|
||||
export type TransferMethod = "stream" | "tar" | "item_sftp" | "direct_rsync";
|
||||
|
||||
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 type {
|
||||
TransferHopId,
|
||||
TransferHopMetrics,
|
||||
TransferTimings,
|
||||
} from "./transfer-stats.js";
|
||||
|
||||
export interface TransferProgress {
|
||||
transferId: string;
|
||||
@@ -204,34 +235,6 @@ interface ActiveXferControl {
|
||||
const activeXferControls = new Map<string, ActiveXferControl>();
|
||||
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 {
|
||||
if (cancelRequestedTransfers.has(transferId)) {
|
||||
throw new TransferCancelledError();
|
||||
@@ -315,8 +318,6 @@ const SMALL_FILE_SYNC_THRESHOLD = 10 * 1024 * 1024;
|
||||
const SFTP_XFER_CHUNK_SIZE = 256 * 1024;
|
||||
/** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */
|
||||
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. */
|
||||
const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024;
|
||||
/** 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;
|
||||
/** Short backoff before opening fresh dedicated SSH sessions. */
|
||||
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 HUNG_TRANSFER_MS = 90_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 {
|
||||
deps: HostTransferDeps;
|
||||
@@ -364,31 +357,6 @@ function buildTransferReconnectContext(
|
||||
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(
|
||||
destSftp: SFTPWrapper,
|
||||
destPath: string,
|
||||
@@ -560,69 +528,6 @@ async function resetDedicatedTransferSessions(
|
||||
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(
|
||||
deps: HostTransferDeps,
|
||||
session: SSHSessionLike,
|
||||
@@ -680,150 +585,6 @@ async function detectTransferPlatform(
|
||||
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(
|
||||
deps: HostTransferDeps,
|
||||
session: SSHSessionLike,
|
||||
@@ -1157,10 +918,6 @@ function finalizeTransfer(
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function verifyTransferredFile(
|
||||
deps: HostTransferDeps,
|
||||
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(
|
||||
deps: HostTransferDeps,
|
||||
transferId: string,
|
||||
@@ -1334,63 +994,6 @@ async function ensureDestParentForFile(
|
||||
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(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePaths: string[],
|
||||
@@ -1430,63 +1033,6 @@ async function scanSourcePathsForRouting(
|
||||
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 {
|
||||
fileSize?: number;
|
||||
initialOffset?: number;
|
||||
@@ -1500,43 +1046,6 @@ interface PipelinedXferOptions {
|
||||
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(
|
||||
deps: HostTransferDeps,
|
||||
ctx: TransferReconnectContext,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferStalledError extends Error {
|
||||
readonly byteOffset?: number;
|
||||
readonly segmentIndex?: number;
|
||||
|
||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||
this.name = "TransferStalledError";
|
||||
this.byteOffset = byteOffset;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
export class TransferConnectionLostError extends Error {
|
||||
constructor(message = "Transfer SSH connection lost") {
|
||||
super(message);
|
||||
this.name = "TransferConnectionLostError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("no response from server") ||
|
||||
msg.includes("connection lost") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("etimedout") ||
|
||||
msg.includes("socket hang up") ||
|
||||
msg.includes("protocol error") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("channel open failure")
|
||||
);
|
||||
}
|
||||
|
||||
export function isRecoverableTransferError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof TransferStalledError ||
|
||||
err instanceof TransferConnectionLostError ||
|
||||
isRecoverableTransferConnectionError(err)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { networkInterfaces } from "os";
|
||||
import { normalizeSftpPath } from "../transfer-paths.js";
|
||||
|
||||
let cachedLocalAddresses: Set<string> | null = null;
|
||||
|
||||
export function normalizeHostAddress(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed.split(":")[0] ?? trimmed;
|
||||
}
|
||||
|
||||
function getLocalAddresses(): Set<string> {
|
||||
if (cachedLocalAddresses) return cachedLocalAddresses;
|
||||
|
||||
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
for (const ifaces of Object.values(networkInterfaces())) {
|
||||
if (!ifaces) continue;
|
||||
for (const iface of ifaces) {
|
||||
if (!iface.internal && iface.family === "IPv4") {
|
||||
addresses.add(iface.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
cachedLocalAddresses = addresses;
|
||||
return addresses;
|
||||
}
|
||||
|
||||
export function isLocalSshEndpoint(ip?: string): boolean {
|
||||
if (!ip) return false;
|
||||
const bare = normalizeHostAddress(ip);
|
||||
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
|
||||
return true;
|
||||
}
|
||||
return getLocalAddresses().has(bare);
|
||||
}
|
||||
|
||||
export function escapeShell(s: string): string {
|
||||
return s.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
export function isRootOnlyPath(path: string): boolean {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
return (
|
||||
normalized === "/" ||
|
||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||
/^[A-Za-z]:$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPermissionError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("eacces") ||
|
||||
msg.includes("access denied")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { basename, joinPath } from "../transfer-paths.js";
|
||||
import {
|
||||
SFTP_OPEN_READ,
|
||||
promisifySftpClose,
|
||||
promisifySftpOpen,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpStat,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export interface FileWorkItem {
|
||||
sourcePath: string;
|
||||
destPath: string;
|
||||
mode: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function collectFileWorkItems(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destRoot: string,
|
||||
destBaseName?: string,
|
||||
): Promise<FileWorkItem[]> {
|
||||
const stats = await promisifySftpStat(sftp, sourcePath);
|
||||
const name = destBaseName ?? basename(sourcePath);
|
||||
const destPath = joinPath(destRoot, name);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return [
|
||||
{
|
||||
sourcePath,
|
||||
destPath,
|
||||
mode: stats.mode & 0o7777,
|
||||
size: stats.size,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: FileWorkItem[] = [];
|
||||
const walk = async (srcDir: string, dstDir: string) => {
|
||||
const entries = await promisifySftpReaddir(sftp, srcDir);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
const srcChild = joinPath(srcDir, entry.filename);
|
||||
const dstChild = joinPath(dstDir, entry.filename);
|
||||
|
||||
if (entry.attrs.isDirectory()) {
|
||||
await walk(srcChild, dstChild);
|
||||
} else if (entry.attrs.isFile()) {
|
||||
items.push({
|
||||
sourcePath: srcChild,
|
||||
destPath: dstChild,
|
||||
mode: entry.attrs.mode & 0o7777,
|
||||
size: entry.attrs.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(sourcePath, destPath);
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function readSftpSample(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
fileSize: number,
|
||||
): Promise<Buffer> {
|
||||
const sampleSize = Math.min(64 * 1024, fileSize);
|
||||
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
|
||||
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
|
||||
try {
|
||||
const buffer = Buffer.alloc(sampleSize);
|
||||
const bytesRead = await new Promise<number>((resolve, reject) => {
|
||||
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
|
||||
if (err) reject(err);
|
||||
else resolve(count);
|
||||
});
|
||||
});
|
||||
return buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await promisifySftpClose(sftp, handle).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||
|
||||
export interface SegmentCopyJob {
|
||||
offset: number;
|
||||
length: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
export function clampParallelSegmentCount(value?: number): number {
|
||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||
}
|
||||
|
||||
export function buildSegmentCopyJobs(
|
||||
fileSize: number,
|
||||
initialOffset: number,
|
||||
destResumeSize: number,
|
||||
): SegmentCopyJob[] {
|
||||
const jobs: SegmentCopyJob[] = [];
|
||||
for (
|
||||
let offset = initialOffset;
|
||||
offset < fileSize;
|
||||
offset += SFTP_XFER_SEGMENT_SIZE
|
||||
) {
|
||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||
if (destResumeSize >= offset + length) {
|
||||
continue;
|
||||
}
|
||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||
jobs.push({
|
||||
offset: start,
|
||||
length: offset + length - start,
|
||||
segmentIndex,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
buildPathFromSegments,
|
||||
joinPath,
|
||||
normalizeSftpPath,
|
||||
splitPathSegments,
|
||||
} from "../transfer-paths.js";
|
||||
import { isRootOnlyPath } from "./transfer-host-utils.js";
|
||||
import {
|
||||
promisifySftpMkdir,
|
||||
promisifySftpReaddir,
|
||||
promisifySftpRmdir,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
} from "./sftp-promisify.js";
|
||||
|
||||
type SFTPWrapper = import("ssh2").SFTPWrapper;
|
||||
|
||||
export async function ensureDirectoryTreeSftp(
|
||||
sftp: SFTPWrapper,
|
||||
dirPath: string,
|
||||
created: Set<string> = new Set(),
|
||||
): Promise<void> {
|
||||
const normalized = normalizeSftpPath(dirPath);
|
||||
if (!normalized || isRootOnlyPath(normalized)) return;
|
||||
|
||||
const { root, segments } = splitPathSegments(normalized);
|
||||
if (segments.length === 0) return;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const current = buildPathFromSegments(root, segments, i + 1);
|
||||
if (created.has(current)) continue;
|
||||
|
||||
try {
|
||||
await promisifySftpMkdir(sftp, current, 0o755);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== "EEXIST") {
|
||||
try {
|
||||
const stats = await promisifySftpStat(sftp, current);
|
||||
if (!stats.isDirectory()) throw err;
|
||||
} catch {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
created.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePathSftp(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
let stats: import("ssh2").Stats;
|
||||
try {
|
||||
stats = await promisifySftpStat(sftp, path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const entries = await promisifySftpReaddir(sftp, path);
|
||||
for (const entry of entries) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
await deletePathSftp(sftp, joinPath(path, entry.filename));
|
||||
}
|
||||
await promisifySftpRmdir(sftp, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stats.isFile()) {
|
||||
await promisifySftpUnlink(sftp, path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||
|
||||
export type TransferHopId =
|
||||
"source_read" | "dest_sftp_write" | "dest_local_write";
|
||||
|
||||
export interface TransferHopMetrics {
|
||||
id: TransferHopId;
|
||||
bytes: number;
|
||||
/** Wall-clock span from first I/O on this hop to last I/O complete. */
|
||||
spanMs: number;
|
||||
mbPerSec: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
prepareDestMs?: number;
|
||||
compressMs?: number;
|
||||
transferMs?: number;
|
||||
extractMs?: number;
|
||||
verifyMs?: number;
|
||||
directBenchmarkMs?: number;
|
||||
relayBenchmarkMs?: number;
|
||||
sourceDeleteMs?: number;
|
||||
totalMs?: number;
|
||||
transferBytes?: number;
|
||||
endToEndMbPerSec?: number;
|
||||
hops?: TransferHopMetrics[];
|
||||
}
|
||||
|
||||
export function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
export function computeTransferMbPerSec(
|
||||
bytes: number,
|
||||
ms: number,
|
||||
): number | undefined {
|
||||
if (ms <= 0 || bytes <= 0) return undefined;
|
||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||
}
|
||||
|
||||
export interface HopWallClock {
|
||||
firstAt: number | null;
|
||||
lastAt: number | null;
|
||||
}
|
||||
|
||||
export function createHopWallClock(): HopWallClock {
|
||||
return { firstAt: null, lastAt: null };
|
||||
}
|
||||
|
||||
export function noteHopStart(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
if (clock.firstAt === null) clock.firstAt = t;
|
||||
}
|
||||
|
||||
export function noteHopEnd(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
clock.lastAt = t;
|
||||
}
|
||||
|
||||
export function hopSpanMs(clock: HopWallClock): number {
|
||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||
}
|
||||
|
||||
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||
let pending = 0;
|
||||
let lastFlush = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (pending > 0) {
|
||||
onProgress?.(pending);
|
||||
pending = 0;
|
||||
lastFlush = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
add(bytes: number) {
|
||||
pending += bytes;
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PipelinedXferStats {
|
||||
bytes: number;
|
||||
sourceReadSpanMs: number;
|
||||
destWriteSpanMs: number;
|
||||
destWriteKind: "sftp" | "local";
|
||||
}
|
||||
|
||||
export function createEmptyXferStats(): PipelinedXferStats {
|
||||
return {
|
||||
bytes: 0,
|
||||
sourceReadSpanMs: 0,
|
||||
destWriteSpanMs: 0,
|
||||
destWriteKind: "sftp",
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeXferStats(
|
||||
target: PipelinedXferStats,
|
||||
source: PipelinedXferStats,
|
||||
): void {
|
||||
target.bytes += source.bytes;
|
||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||
target.destWriteKind = source.destWriteKind;
|
||||
}
|
||||
|
||||
export function buildTransferHopTimings(
|
||||
stats: PipelinedXferStats,
|
||||
transferMs: number,
|
||||
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
|
||||
const hops: TransferHopMetrics[] = [];
|
||||
|
||||
const sourceRate = computeTransferMbPerSec(
|
||||
stats.bytes,
|
||||
stats.sourceReadSpanMs,
|
||||
);
|
||||
if (sourceRate !== undefined) {
|
||||
hops.push({
|
||||
id: "source_read",
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.sourceReadSpanMs,
|
||||
mbPerSec: sourceRate,
|
||||
});
|
||||
}
|
||||
|
||||
const destHopId: TransferHopId =
|
||||
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
|
||||
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
|
||||
if (destRate !== undefined) {
|
||||
hops.push({
|
||||
id: destHopId,
|
||||
bytes: stats.bytes,
|
||||
spanMs: stats.destWriteSpanMs,
|
||||
mbPerSec: destRate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
transferBytes: stats.bytes,
|
||||
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
|
||||
hops,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Client as SSHClient } from "ssh2";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.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 { getJumpHostSocks5Config } from "./jump-host-proxy.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(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
@@ -76,7 +89,11 @@ export async function createJumpHostChain(
|
||||
totalHops,
|
||||
});
|
||||
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,
|
||||
);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
const connected = await new Promise<boolean>(async (resolve) => {
|
||||
const readyTimeoutMs = 60000;
|
||||
const timeout = setTimeout(() => {
|
||||
lastError = new Error(
|
||||
`Timed out waiting for jump host ${i + 1}/${totalHops} to authenticate`,
|
||||
);
|
||||
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", () => {
|
||||
clearTimeout(timeout);
|
||||
@@ -119,6 +145,7 @@ export async function createJumpHostChain(
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
lastError = err;
|
||||
fileLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
@@ -145,7 +172,7 @@ export async function createJumpHostChain(
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
readyTimeout: readyTimeoutMs,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
@@ -190,11 +217,19 @@ export async function createJumpHostChain(
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
try {
|
||||
connectConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
jumpHostConfig.key,
|
||||
jumpHostConfig.keyPassword,
|
||||
);
|
||||
} 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) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
@@ -237,6 +272,7 @@ export async function createJumpHostChain(
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
lastError = err;
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +290,14 @@ export async function createJumpHostChain(
|
||||
|
||||
if (!connected) {
|
||||
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;
|
||||
@@ -262,6 +305,7 @@ export async function createJumpHostChain(
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
if (error instanceof JumpHostChainError) throw error;
|
||||
fileLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
|
||||
@@ -576,16 +576,32 @@ class PollingManager {
|
||||
} else {
|
||||
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 = {
|
||||
status: statusAfterReachabilityCheck(
|
||||
isOnline,
|
||||
this.statusStore.get(refreshedHost.id)?.status,
|
||||
),
|
||||
status:
|
||||
authenticated === undefined
|
||||
? statusAfterReachabilityCheck(
|
||||
isOnline,
|
||||
this.statusStore.get(refreshedHost.id)?.status,
|
||||
)
|
||||
: statusAfterAuthentication(authenticated),
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||
if (isOnline && this.activeViewers.has(refreshedHost.id)) {
|
||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (config?.statsConfig.metricsEnabled) {
|
||||
this.scheduleInitialMetricsPoll(config.host, config.viewerUserId);
|
||||
}
|
||||
|
||||
+93
-89
@@ -4,6 +4,7 @@ import { SerialPort } from "serialport";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { parseWsMessage } from "../utils/ws-message.js";
|
||||
|
||||
interface SerialConnectData {
|
||||
path: string;
|
||||
@@ -13,11 +14,6 @@ interface SerialConnectData {
|
||||
parity?: "none" | "even" | "odd";
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data?: SerialConnectData | string | unknown;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const wss = new WebSocketServer({ port: 30011 });
|
||||
@@ -93,109 +89,117 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
|
||||
ws.on("message", async (raw: RawData) => {
|
||||
let parsed: WebSocketMessage;
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString()) as WebSocketMessage;
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to list ports"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "connect": {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
try {
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to list ports"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
port = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
case "connect": {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
port.open((err) => {
|
||||
if (err) {
|
||||
sshLogger.error("Serial port open failed", err, {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
sshLogger.info("Serial port opened", {
|
||||
operation: "serial_open",
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
port = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
userId,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
port.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
port.open((err) => {
|
||||
if (err) {
|
||||
sshLogger.error("Serial port open failed", err, {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
sshLogger.info("Serial port opened", {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
userId,
|
||||
});
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
port.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
port.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
|
||||
port.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to open serial port"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
port.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) {
|
||||
send({ type: "error", data: err.message });
|
||||
port.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to open serial port"),
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
break;
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) {
|
||||
send({ type: "error", data: err.message });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import dgram from "dgram";
|
||||
import net from "net";
|
||||
import ssh2Pkg, {
|
||||
type BaseAgent as BaseAgentType,
|
||||
type GetStreamCallback,
|
||||
type IdentityCallback,
|
||||
type KnownPublicKeys,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
type KnownPublicKey = KnownPublicKeys[number];
|
||||
|
||||
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
@@ -34,6 +39,23 @@ export class MemoryAgent extends BaseAgent {
|
||||
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(
|
||||
_pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
@@ -85,6 +107,84 @@ export async function resolveAgentSocket(
|
||||
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(
|
||||
connectConfig: Record<string, unknown>,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
@@ -93,7 +193,21 @@ export async function applyAgentAuth(
|
||||
if ("error" in result) return result;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. " +
|
||||
'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
|
||||
* the socket. Callers whose host-resolution is wrapped in a "failed to resolve
|
||||
|
||||
+829
-781
File diff suppressed because it is too large
Load Diff
@@ -177,8 +177,8 @@ export function buildPaneMetrics(
|
||||
const treePids: number[] = [];
|
||||
const queue = [pane.pid];
|
||||
const seen = new Set<number>();
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift()!;
|
||||
for (let cursor = 0; cursor < queue.length; cursor++) {
|
||||
const pid = queue[cursor];
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
if (byPid.has(pid)) treePids.push(pid);
|
||||
@@ -225,9 +225,19 @@ export function attachPanesToWindows(
|
||||
windows: Map<string, TmuxWindow[]>,
|
||||
panes: RawPane[],
|
||||
): 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) {
|
||||
const sessionWindows = windows.get(pane.sessionName) || [];
|
||||
const window = sessionWindows.find((w) => w.index === pane.windowIndex);
|
||||
const window = windowsBySessionAndIndex
|
||||
.get(pane.sessionName)
|
||||
?.get(pane.windowIndex);
|
||||
if (window) {
|
||||
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
||||
window.panes.push(paneFields);
|
||||
|
||||
+20
-2
@@ -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) => {
|
||||
systemLogger.error("Uncaught exception occurred", error, {
|
||||
operation: "error_handling",
|
||||
fatal: isFatalError(error),
|
||||
});
|
||||
process.exit(1);
|
||||
if (isFatalError(error)) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
systemLogger.error("Unhandled promise rejection", reason, {
|
||||
operation: "error_handling",
|
||||
fatal: isFatalError(reason),
|
||||
});
|
||||
process.exit(1);
|
||||
if (isFatalError(reason)) {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
} catch (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", () => ({
|
||||
createCurrentAutomationRepository: () => repository,
|
||||
}));
|
||||
@@ -93,12 +99,41 @@ beforeEach(() => {
|
||||
nextRunId = 1;
|
||||
nextStepRowId = 1;
|
||||
vi.clearAllMocks();
|
||||
resolveHostById.mockResolvedValue(null);
|
||||
executeStep.mockResolvedValue({ success: true, output: "ok" });
|
||||
// The singleton carries in-flight state between tests.
|
||||
(AutomationEngine as unknown as { instance?: unknown }).instance = undefined;
|
||||
});
|
||||
|
||||
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 () => {
|
||||
defineAutomation([
|
||||
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 { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostRepository } from "../../../database/repositories/host-repository.js";
|
||||
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||
|
||||
describe("HostRepository.reorderForUser", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
@@ -72,3 +73,100 @@ describe("HostRepository.reorderForUser", () => {
|
||||
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({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: 1,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("resolveProxmoxImportAuth", () => {
|
||||
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: 1,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("resolveProxmoxImportAuth", () => {
|
||||
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 42,
|
||||
overrideCredentialUsername: 1,
|
||||
overrideCredentialUsername: 0,
|
||||
});
|
||||
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
|
||||
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,
|
||||
verifyOIDCToken,
|
||||
describeFetchFailure,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
@@ -281,6 +282,7 @@ describe("getOIDCConfigFromEnv", () => {
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_ALLOWED_USERS",
|
||||
"OIDC_ADMIN_GROUP",
|
||||
"OIDC_ENV_OVERRIDE",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
@@ -334,6 +336,12 @@ describe("getOIDCConfigFromEnv", () => {
|
||||
expect(config?.identifier_path).toBe("email");
|
||||
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", () => {
|
||||
|
||||
@@ -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 { generateKeyPairSync } from "crypto";
|
||||
import ssh2Pkg, { type ParsedKey } from "ssh2";
|
||||
|
||||
const mockAccess = vi.fn();
|
||||
|
||||
@@ -6,7 +8,57 @@ vi.mock("fs/promises", () => ({
|
||||
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", () => {
|
||||
const originalEnv = process.env.SSH_AUTH_SOCK;
|
||||
@@ -101,3 +153,78 @@ describe("resolveAgentSocket", () => {
|
||||
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,
|
||||
HostNotOnThisServerError,
|
||||
normalizeHostAddress,
|
||||
resolveServerJumpHosts,
|
||||
} 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", () => {
|
||||
it("survives the catch blocks that swallow resolution failures", () => {
|
||||
// SFTP host resolution sits inside "failed to resolve credentials, carry
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type ProcessInfo,
|
||||
type TmuxWindow,
|
||||
} from "../../../hosts/tmux/monitor-helpers.js";
|
||||
|
||||
function join(...fields: (string | number)[]): string {
|
||||
@@ -194,6 +196,28 @@ describe("buildPaneMetrics", () => {
|
||||
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
||||
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", () => {
|
||||
@@ -214,6 +238,29 @@ describe("attachPanesToWindows", () => {
|
||||
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
parsePublicKey,
|
||||
preparePrivateKeyForSSH2,
|
||||
isPrivateKeyPassphraseError,
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
} 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", () => {
|
||||
it("maps known key types to friendly names", () => {
|
||||
expect(getFriendlyKeyTypeName("ssh-rsa")).toBe("RSA");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseWsMessage,
|
||||
asObject,
|
||||
asString,
|
||||
toTerminalDimension,
|
||||
WsMessageError,
|
||||
} from "../../utils/ws-message.js";
|
||||
|
||||
const frame = (s: string) => Buffer.from(s, "utf8");
|
||||
|
||||
describe("parseWsMessage", () => {
|
||||
it("parses a well-formed message", () => {
|
||||
expect(parseWsMessage(frame('{"type":"ping"}'))).toEqual({
|
||||
type: "ping",
|
||||
data: undefined,
|
||||
});
|
||||
expect(parseWsMessage(frame('{"type":"input","data":"ls"}'))).toEqual({
|
||||
type: "input",
|
||||
data: "ls",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects JSON that parses but cannot be destructured", () => {
|
||||
// The original DoS: JSON.parse("null") succeeds, so it escaped the
|
||||
// try/catch and threw a TypeError on destructure.
|
||||
for (const payload of ["null", "123", '"str"', "[1,2]", "true"]) {
|
||||
expect(() => parseWsMessage(frame(payload))).toThrow(WsMessageError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseWsMessage(frame("{oops"))).toThrow(WsMessageError);
|
||||
expect(() => parseWsMessage(frame(""))).toThrow(WsMessageError);
|
||||
});
|
||||
|
||||
it("rejects a missing or non-string type", () => {
|
||||
expect(() => parseWsMessage(frame("{}"))).toThrow(WsMessageError);
|
||||
expect(() => parseWsMessage(frame('{"type":5}'))).toThrow(WsMessageError);
|
||||
expect(() => parseWsMessage(frame('{"type":null}'))).toThrow(
|
||||
WsMessageError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized frames", () => {
|
||||
const huge = Buffer.alloc(1024 * 1024 + 1, 0x20);
|
||||
expect(() => parseWsMessage(huge)).toThrow(WsMessageError);
|
||||
});
|
||||
|
||||
it("never throws a TypeError for any malformed input", () => {
|
||||
const payloads = [
|
||||
"null",
|
||||
"0",
|
||||
"[]",
|
||||
"{}",
|
||||
'{"type":{}}',
|
||||
'{"data":"x"}',
|
||||
"undefined",
|
||||
'{"type":"a","data":null}',
|
||||
];
|
||||
for (const p of payloads) {
|
||||
try {
|
||||
parseWsMessage(frame(p));
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(WsMessageError);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("asObject / asString", () => {
|
||||
it("narrows without throwing", () => {
|
||||
expect(asObject({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(asObject(null)).toEqual({});
|
||||
expect(asObject([1])).toEqual({});
|
||||
expect(asObject("x")).toEqual({});
|
||||
expect(asString("x")).toBe("x");
|
||||
expect(asString(5)).toBe("");
|
||||
expect(asString(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTerminalDimension", () => {
|
||||
it("accepts sane values", () => {
|
||||
expect(toTerminalDimension(80)).toBe(80);
|
||||
expect(toTerminalDimension("120")).toBe(120);
|
||||
expect(toTerminalDimension(24.7)).toBe(24);
|
||||
});
|
||||
|
||||
it("rejects values that would poison setWindow", () => {
|
||||
for (const bad of [0, -1, NaN, Infinity, null, undefined, "abc", {}]) {
|
||||
expect(toTerminalDimension(bad)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps absurdly large values", () => {
|
||||
expect(toTerminalDimension(1e9)).toBe(10000);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Agent, ProxyAgent } from "undici";
|
||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici-types";
|
||||
|
||||
const directAgent = new Agent({
|
||||
@@ -39,3 +39,13 @@ export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
|
||||
export function getFetchDispatcher(targetUrl: string): Dispatcher {
|
||||
return getProxyAgent(targetUrl) ?? (directAgent as unknown as Dispatcher);
|
||||
}
|
||||
|
||||
export function fetchWithProxy(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
return undiciFetch(url, {
|
||||
...init,
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ export function preparePrivateKeyForSSH2(
|
||||
return Buffer.from(cleanKey, "utf8");
|
||||
}
|
||||
|
||||
export function isPrivateKeyPassphraseError(error: unknown): boolean {
|
||||
return /passphrase/i.test(getErrorMessage(error, ""));
|
||||
}
|
||||
|
||||
export function parseSSHKey(
|
||||
privateKeyData: string,
|
||||
passphrase?: string,
|
||||
|
||||
@@ -123,6 +123,106 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "File Manager",
|
||||
description: "SSH file management operations",
|
||||
},
|
||||
{
|
||||
name: "SSH",
|
||||
description: "SSH host management and configuration",
|
||||
},
|
||||
{
|
||||
name: "Host Enrollment",
|
||||
description: "Host enrollment and onboarding",
|
||||
},
|
||||
{
|
||||
name: "Fleets",
|
||||
description: "Fleet grouping, membership, and inventory",
|
||||
},
|
||||
{
|
||||
name: "Workspaces",
|
||||
description: "Saved tab and split layouts",
|
||||
},
|
||||
{
|
||||
name: "Open Tabs",
|
||||
description: "Per-user open tab state",
|
||||
},
|
||||
{
|
||||
name: "Automations",
|
||||
description: "Scheduled and triggered automations",
|
||||
},
|
||||
{
|
||||
name: "Guacamole",
|
||||
description: "RDP, VNC, and Telnet remote desktop sessions",
|
||||
},
|
||||
{
|
||||
name: "Proxmox",
|
||||
description: "Proxmox host integration",
|
||||
},
|
||||
{
|
||||
name: "Proxmox Stats",
|
||||
description: "Proxmox node and VM statistics",
|
||||
},
|
||||
{
|
||||
name: "Session Sharing",
|
||||
description: "Live terminal session collaboration",
|
||||
},
|
||||
{
|
||||
name: "Session Logs",
|
||||
description: "Session recording and playback",
|
||||
},
|
||||
{
|
||||
name: "Homepage",
|
||||
description: "Homepage service links and layout",
|
||||
},
|
||||
{
|
||||
name: "Audit",
|
||||
description: "Audit log querying and export",
|
||||
},
|
||||
{
|
||||
name: "API Keys",
|
||||
description: "API key management",
|
||||
},
|
||||
{
|
||||
name: "SSO",
|
||||
description: "Single sign-on provider configuration",
|
||||
},
|
||||
{
|
||||
name: "WebAuthn",
|
||||
description: "Passkey registration and authentication",
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
description: "HashiCorp Vault SSH signing profiles",
|
||||
},
|
||||
{
|
||||
name: "Termix ID",
|
||||
description: "Built-in SSH certificate authority",
|
||||
},
|
||||
{
|
||||
name: "Tailscale",
|
||||
description: "Tailscale network integration",
|
||||
},
|
||||
{
|
||||
name: "Sync",
|
||||
description: "Remote sync between desktop and server",
|
||||
},
|
||||
{
|
||||
name: "Tunnel Presets",
|
||||
description: "Saved tunnel configurations",
|
||||
},
|
||||
{
|
||||
name: "User Preferences",
|
||||
description: "Per-user application preferences",
|
||||
},
|
||||
{
|
||||
name: "UI Preferences",
|
||||
description: "Interface layout and display preferences",
|
||||
},
|
||||
{
|
||||
name: "Host Sidebar",
|
||||
description: "Host sidebar display preferences",
|
||||
},
|
||||
{
|
||||
name: "Credential Sidebar",
|
||||
description: "Credential sidebar display preferences",
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { RawData } from "ws";
|
||||
|
||||
// Cap on a single decoded text frame. Anything larger is almost certainly
|
||||
// abuse - the legitimate control messages here are tiny, and terminal input is
|
||||
// bounded by what a user can type or paste.
|
||||
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
export class WsMessageError extends Error {}
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a WebSocket frame into a plain message object.
|
||||
*
|
||||
* Throws WsMessageError - never a TypeError - for anything malformed, so
|
||||
* callers can reject the frame instead of the parse blowing up the handler.
|
||||
* `JSON.parse` happily returns null, numbers and arrays, none of which are
|
||||
* safe to destructure, so the shape is checked here rather than at each site.
|
||||
*/
|
||||
export function parseWsMessage(raw: RawData): {
|
||||
type: string;
|
||||
data: unknown;
|
||||
} {
|
||||
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
|
||||
throw new WsMessageError("Message too large");
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new WsMessageError("Invalid JSON");
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new WsMessageError("Message must be a JSON object");
|
||||
}
|
||||
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new WsMessageError("Message type must be a string");
|
||||
}
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
/** Narrow unknown payload data to a plain object without throwing. */
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Narrow unknown payload data to a string without throwing. */
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a client-supplied terminal width/height to a sane integer.
|
||||
* Returns 0 when the value is unusable, so callers can skip the resize
|
||||
* rather than pass NaN or a negative into ssh2's setWindow.
|
||||
*/
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
Vendored
+5
-1
@@ -40,7 +40,10 @@ export interface ElectronAPI {
|
||||
|
||||
getServerConfig: () => Promise<ServerConfig>;
|
||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
|
||||
testServerConnection: (
|
||||
serverUrl: string,
|
||||
allowInvalidCertificate?: boolean,
|
||||
) => Promise<ConnectionTestResult>;
|
||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
||||
saveC2STunnelConfig: (
|
||||
config: unknown[],
|
||||
@@ -171,6 +174,7 @@ export interface ElectronAPI {
|
||||
startLocalTerminal(dimensions: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
shell?: "default" | "wsl";
|
||||
}): Promise<{ sessionId: string; shell: string }>;
|
||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
||||
|
||||
@@ -715,6 +715,7 @@ export interface TerminalConfig {
|
||||
linkClickBehavior?: "confirm" | "direct";
|
||||
useSSHTitle?: boolean;
|
||||
agentSocketPath?: string;
|
||||
agentIdentity?: string;
|
||||
customThemeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
|
||||
@@ -55,6 +55,21 @@ export async function updateCredential(
|
||||
}
|
||||
}
|
||||
|
||||
export async function duplicateCredential(
|
||||
credentialId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/credentials/${credentialId}/duplicate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "duplicate credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCredential(
|
||||
credentialId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { AxiosInstance } from "axios";
|
||||
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
|
||||
@@ -185,10 +186,18 @@ export async function getGuacamoleTokenFromHost(
|
||||
password?: string;
|
||||
domain?: string;
|
||||
},
|
||||
syncId?: string | null,
|
||||
): Promise<GuacamoleTokenResponse> {
|
||||
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(
|
||||
`/guacamole/connect-host/${hostId}`,
|
||||
`/guacamole/connect-host/${targetHostId}`,
|
||||
{
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(promptedCredentials?.username
|
||||
|
||||
@@ -549,14 +549,26 @@ export async function downloadSSHFile(
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadProgressEvent {
|
||||
loaded: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
onProgress?: (event: DownloadProgressEvent) => void,
|
||||
): Promise<void> {
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ 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 fileName = filePath.split("/").pop() || "download";
|
||||
|
||||
@@ -257,6 +257,7 @@ export async function updateOidcAutoProvision(
|
||||
|
||||
export async function getOidcSilentLoginDefault(): Promise<{
|
||||
enabled: boolean;
|
||||
locked?: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-silent-login-default");
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type WebAuthnUserVerification = "discouraged" | "preferred" | "required";
|
||||
@@ -23,6 +29,53 @@ type RegistrationOptionsResponse = {
|
||||
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<{
|
||||
credentials: WebAuthnCredentialSummary[];
|
||||
}> {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Fingerprint,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
requestTrustedProxyLogin,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import { isPasskeySupported, loginWithPasskey } from "@/api/webauthn-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import {
|
||||
@@ -217,6 +219,13 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [providerLoading, setProviderLoading] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
const [passkeySupported] = useState(() => {
|
||||
try {
|
||||
return isPasskeySupported();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const [username, setUsername] = 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) {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) {
|
||||
@@ -1469,6 +1544,20 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
</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>
|
||||
)}
|
||||
@@ -1536,6 +1625,20 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
@@ -34,20 +34,21 @@ export function ConnectionLogPanel({
|
||||
className,
|
||||
}: ConnectionLogPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const connectionLog = useOptionalConnectionLog();
|
||||
const { logs, clearLogs, isExpanded, toggleExpanded, setIsExpanded } =
|
||||
useConnectionLog();
|
||||
connectionLog ?? {};
|
||||
const lastLogRef = useRef<HTMLDivElement>(null);
|
||||
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasConnectionError) {
|
||||
if (hasConnectionError && setIsExpanded) {
|
||||
setManuallyCollapsed(false);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [hasConnectionError, setIsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !hasConnectionError && !isConnecting) {
|
||||
if (isConnected && !hasConnectionError && !isConnecting && clearLogs) {
|
||||
clearLogs();
|
||||
setManuallyCollapsed(false);
|
||||
}
|
||||
@@ -60,7 +61,9 @@ export function ConnectionLogPanel({
|
||||
}, [logs]);
|
||||
|
||||
const shouldShow =
|
||||
!isConnected && (isConnecting || hasConnectionError || logs.length > 0);
|
||||
!!connectionLog &&
|
||||
!isConnected &&
|
||||
(isConnecting || hasConnectionError || logs.length > 0);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
|
||||
@@ -154,7 +154,10 @@ export function ProxmoxDiscoverDialog({
|
||||
// the real IP. Re-sync keeps the manual value (guest.ip || existing.ip).
|
||||
ip: g.ip || "0.0.0.0",
|
||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||
username: defaultUsername ?? "root",
|
||||
username:
|
||||
importAuth.authType === "credential"
|
||||
? ""
|
||||
: (defaultUsername ?? "root"),
|
||||
folder: importFolder,
|
||||
// Inherit the jump-host chain from the scanned Proxmox host so the
|
||||
// imported guests are reachable the same way; user can override.
|
||||
|
||||
@@ -30,7 +30,7 @@ export function resolveProxmoxImportAuth(
|
||||
return {
|
||||
authType: "credential",
|
||||
credentialId,
|
||||
overrideCredentialUsername: true,
|
||||
overrideCredentialUsername: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
getAiProviderModels,
|
||||
probeAiModels,
|
||||
updateAiProvider,
|
||||
type AiProvider,
|
||||
type AiProviderType,
|
||||
} from "@/api/ai-api";
|
||||
@@ -68,6 +70,159 @@ interface AiProviderSettingsProps {
|
||||
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({
|
||||
providers,
|
||||
onChanged,
|
||||
@@ -75,6 +230,7 @@ export function AiProviderSettings({
|
||||
}: AiProviderSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||
const [label, setLabel] = useState("");
|
||||
@@ -173,32 +329,67 @@ export function AiProviderSettings({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
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="truncate text-sm font-medium">{provider.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{provider.providerType}
|
||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||
{providers.map((provider) =>
|
||||
editingId === provider.id ? (
|
||||
<AiProviderEditForm
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onSaved={() => {
|
||||
setEditingId(null);
|
||||
onChanged(provider.id);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={provider.id}
|
||||
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="truncate text-sm font-medium">
|
||||
{provider.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{provider.providerType}
|
||||
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
|
||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||
</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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
|
||||
{!adding && (
|
||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("ai.addProvider")}
|
||||
</Button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useWindowManager,
|
||||
} from "./components/WindowManager.tsx";
|
||||
import { FileWindow } from "./components/FileWindow.tsx";
|
||||
import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
|
||||
import { DiffWindow } from "./components/DiffWindow.tsx";
|
||||
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
|
||||
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
|
||||
@@ -976,19 +977,14 @@ function FileManagerContent({
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [currentPath]);
|
||||
|
||||
async function handleItemsDropped(items: DataTransferItemList) {
|
||||
async function handleItemsDropped(entries: FileSystemEntry[]) {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
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 emptyDirs: string[] = [];
|
||||
|
||||
async function readEntry(
|
||||
entry: FileSystemEntry,
|
||||
@@ -999,51 +995,77 @@ function FileManagerContent({
|
||||
(entry as FileSystemFileEntry).file(resolve, reject),
|
||||
);
|
||||
files.push({ file, relativePath: path });
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
let batch: FileSystemEntry[];
|
||||
do {
|
||||
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
||||
reader.readEntries(resolve, reject),
|
||||
);
|
||||
for (const child of batch) {
|
||||
await readEntry(child, `${path}/${child.name}`);
|
||||
}
|
||||
} while (batch.length > 0);
|
||||
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 children: FileSystemEntry[] = [];
|
||||
for (;;) {
|
||||
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
||||
reader.readEntries(resolve, reject),
|
||||
);
|
||||
if (batch.length === 0) break;
|
||||
children.push(...batch);
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
emptyDirs.push(path);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
await readEntry(child, `${path}/${child.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
await readEntry(entry, entry.name);
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
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(
|
||||
`Uploading ${files.length} file(s)...`,
|
||||
t("fileManager.uploadingFolderFiles", { count: files.length }),
|
||||
{ duration: Infinity },
|
||||
);
|
||||
|
||||
const failed: string[] = [];
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const base = currentPath.endsWith("/") ? currentPath : currentPath + "/";
|
||||
|
||||
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("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
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) {
|
||||
const parentPath = currentPath.endsWith("/")
|
||||
? currentPath + dir.split("/").slice(0, -1).join("/")
|
||||
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
|
||||
const parentDir = dir.split("/").slice(0, -1).join("/");
|
||||
const targetPath = parentDir ? `${base}${parentDir}/` : base;
|
||||
const folderName = dir.split("/").pop()!;
|
||||
const targetPath = parentPath.endsWith("/")
|
||||
? parentPath
|
||||
: parentPath + "/";
|
||||
try {
|
||||
await createSSHFolder(
|
||||
sshSessionId,
|
||||
@@ -1060,23 +1082,37 @@ function FileManagerContent({
|
||||
const dirPart = relativePath.includes("/")
|
||||
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
||||
: "";
|
||||
const uploadPath = dirPart
|
||||
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
|
||||
dirPart +
|
||||
"/"
|
||||
: currentPath;
|
||||
const uploadPath = dirPart ? `${base}${dirPart}/` : currentPath;
|
||||
|
||||
await uploadSSHFile(
|
||||
sshSessionId,
|
||||
uploadPath,
|
||||
file.name,
|
||||
file,
|
||||
currentHost?.id,
|
||||
);
|
||||
try {
|
||||
await uploadSSHFile(
|
||||
sshSessionId,
|
||||
uploadPath,
|
||||
file.name,
|
||||
file,
|
||||
currentHost?.id,
|
||||
);
|
||||
} catch (error) {
|
||||
failed.push(relativePath);
|
||||
console.error(`Failed to upload ${relativePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (error) {
|
||||
toast.dismiss(progressToast);
|
||||
@@ -1166,14 +1202,51 @@ function FileManagerContent({
|
||||
async function handleDownloadFile(file: FileItem) {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
const toastId = `download-${file.path}-${Date.now()}`;
|
||||
let lastLoaded = 0;
|
||||
let lastTime = Date.now();
|
||||
let mbPerSec: number | undefined;
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
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(
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
{ id: toastId },
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const err = error instanceof Error ? error : null;
|
||||
@@ -1187,9 +1260,10 @@ function FileManagerContent({
|
||||
ip: currentHost?.ip,
|
||||
port: currentHost?.port,
|
||||
}),
|
||||
{ id: toastId },
|
||||
);
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToDownloadFile"));
|
||||
toast.error(t("fileManager.failedToDownloadFile"), { id: toastId });
|
||||
}
|
||||
console.error("Download failed:", error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
@@ -76,14 +76,20 @@ function Breadcrumb({
|
||||
<React.Fragment key={i}>
|
||||
{part === "" && i === 0 ? (
|
||||
<button
|
||||
onClick={() => navigateTo("/")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateTo("/");
|
||||
}}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{t("fileManager.root")}
|
||||
</button>
|
||||
) : part !== "" ? (
|
||||
<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"
|
||||
>
|
||||
{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({
|
||||
t,
|
||||
currentPath,
|
||||
@@ -182,9 +265,12 @@ export function FileManagerToolbar({
|
||||
</Button>
|
||||
</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">
|
||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||
</div>
|
||||
<PathBar
|
||||
currentPath={currentPath}
|
||||
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">
|
||||
{selectedFiles.length > 0 && (
|
||||
@@ -340,9 +426,12 @@ export function FileManagerToolbar({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||
</div>
|
||||
<PathBar
|
||||
currentPath={currentPath}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
onFilesDropped: (files: FileList) => void;
|
||||
onItemsDropped?: (items: DataTransferItemList) => void;
|
||||
onItemsDropped?: (entries: FileSystemEntry[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
@@ -119,24 +119,29 @@ export function useDragAndDrop({
|
||||
e.preventDefault();
|
||||
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({
|
||||
isDragging: false,
|
||||
dragCounter: 0,
|
||||
draggedFiles: [],
|
||||
});
|
||||
|
||||
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
|
||||
const hasDirectory = Array.from(e.dataTransfer.items).some(
|
||||
(item) => item.webkitGetAsEntry?.()?.isDirectory,
|
||||
);
|
||||
if (hasDirectory) {
|
||||
onItemsDropped(e.dataTransfer.items);
|
||||
return;
|
||||
}
|
||||
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
|
||||
onItemsDropped(entries);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user