* Improve Docker container list UI

* Rework SSH tunnel forwarding

* Update macOS Electron packaging

* Optimize frontend bundle splitting

* Add beta version update status

* Add client tunnel preset management

* Secure cookie authentication flows

* Add client tunnel bridge support

* Preserve sessions on restart

* Update runtime to Node 24

* Add client remote tunnel support

* Fix stale frontend cache handling

* Fix Docker image platforms for Node 24

* Fix Electron packaging workflows

* Fix client auth cache after upgrades

* chore: cleanup files

* fix: npm i error

* Fix OIDC auth cookie readiness

* Fix Docker npm ci config

* Add react-is peer dependency

* Fix Electron auth and cache handling

* Improve terminal clipboard and refresh actions

* feat: add API keys

* feat: improve lazy loading with loading spinners

* feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735)

* feat: integrate FolderTree component with lazy-loading for file manager sidebar

- Add motion animation library (v12.38.0) for smooth UI transitions
- Create new FolderTree component with advanced keyboard navigation support
- Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents
- Implement lazy-loading strategy for directory tree in FileManagerSidebar
- Refactor FileManagerSidebar with improved code organization and better separation of concerns
- Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard
- Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json

BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead.

- Improves UX with smooth animations and better folder navigation
- Reduces initial load time through lazy-loading subdirectories
- Enhances accessibility with ARIA labels and keyboard navigation
- Maintains dark mode support and proper styling

* fix: incorrect use of the theme system and linked file manger sidebar with current folder

---------

Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733)

* Fix Docker build info generation

* Remove unused node-fetch dependency

* feat: prompt user for SSH key passphrase on use (#715)

When an encrypted SSH key has no stored passphrase, show a lightweight
dialog prompting the user to enter it at connection time instead of
failing with a parse error. Supports both desktop and mobile terminals.

Closes Termix-SSH/Support#354

* fix: prevent session crash when uploading to permission-denied directory (#716)

- Wrap writeFile sftp.stat callback in try-catch to prevent uncaught
  exceptions from escaping the callback into the event loop
- Add missing stream.stderr error handler in writeFile fallback to
  prevent unhandled error events from crashing the process
- Remove bogus activeOperations decrement in both writeFile and
  uploadFile fallback methods (counter was never incremented)
- Add res.headersSent checks in fallback disconnect paths to prevent
  ERR_HTTP_HEADERS_SENT crashes

Closes Termix-SSH/Support#652

* feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718)

Support LOG_TIMESTAMP_FORMAT environment variable with values:
- "24h": 24-hour format (14:58:45)
- "iso": ISO 8601 format (2026-04-25T14:58:45.000Z)
- default: locale format (2:58:45 PM)

Closes Termix-SSH/Support#650

* feat: open file manager at terminal current working directory (#719)

* feat: open file manager at terminal current working directory

When right-clicking in the terminal and selecting "Open File Manager
Here", query the current working directory via a separate SSH exec
channel and pass it as the initial path to the file manager tab.

Closes Termix-SSH/Support#649

* chore: sync package-lock.json with node-fetch and deps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: remove undefined TerminalContextMenu from bad merge resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: show reconnect overlay when SSH server reboots (#720)

When the remote server reboots, the SSH connection closes while the
stream is still active. The close handler only sent the "disconnected"
message when sshStream was null, so the frontend never received the
disconnect notification and hung with a blinking cursor.

Change the else-if condition to always send the "disconnected" message
regardless of stream state.

Closes Termix-SSH/Support#648

* feat: support read-only Docker container mode (#721)

Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/
to /tmp/nginx/ so the container can run with read_only: true. Template
files remain in /app/nginx/ as read-only assets.

Users can now harden the container with:
  read_only: true
  tmpfs:
    - /tmp

Closes Termix-SSH/Support#647

* fix: allow editing host folder without re-entering password (#722)

When editing an existing host, the password field is stripped by the
backend for security. The form validation treated the empty password
as invalid, disabling the Update Host button even for non-auth changes
like folder assignment.

Use an "existing_password" sentinel (mirroring the existing
"existing_key" pattern) to represent an unchanged password during
editing, skip validation for it, and omit it from the update payload.

Closes Termix-SSH/Support#645

* fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723)

Distinguish between graceful shell exit and unexpected disconnection
using the stream close event's exit code. When the shell exits normally
(code != null), send "session_ended" instead of "disconnected". The
frontend auto-closes the tab on session_ended, and shows the reconnect
overlay only on unexpected disconnections.

Closes Termix-SSH/Support#643

* fix: reattach existing SSH session on WebSocket reconnect (#724)

WebSocket reconnection was always creating a new SSH connection with
full authentication instead of reattaching to the existing SSH session.
The condition `!isReconnectingRef.current` prevented session reattach
during reconnection, causing repeated password auth attempts that
trigger SSHGuard/fail2ban blocking.

Remove the guard so reconnection tries to reattach the persisted
session first. If the session has expired, the backend sends
sessionExpired and the frontend falls back to a new connection.

Closes Termix-SSH/Support#644

* fix: prevent browser crash when uploading large files (>100MB) (#725)

The file-to-base64 conversion used a byte-by-byte string concatenation
loop (String.fromCharCode + btoa), which allocated ~3x the file size
in intermediate strings, causing the browser tab to OOM on files over
~100MB.

Replace with FileReader.readAsDataURL which delegates base64 encoding
to the browser engine natively, avoiding the intermediate allocations.

Closes Termix-SSH/Support#577

* fix: support SSH multi-factor auth with publickey + password (#726)

When sshd requires AuthenticationMethods publickey,password, the
connection failed because the key auth branch only set privateKey
without also setting password. After publickey partial auth succeeded,
ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of
password, which the server rejected.

Pass the credential password alongside the private key so ssh2 can
complete the password step after publickey succeeds.

Closes Termix-SSH/Support#629

* feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727)

The `allow_registration` setting blocks both password-based and OIDC user
creation. Admins who want to close password registration but still onboard
new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist)
have no way to do that today.

Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC
callback skips the `allow_registration` settings check while still honoring
the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST
/users/create` continues to respect `allow_registration`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: lazy load locales, file previews, and decouple startup imports (#729)

* perf: lazy load locale bundles

* perf: lazy load file preview modules

* perf: avoid eager api client load on startup

* chore: remove dead code, tighten types, fix lint warnings (#730)

* chore: clean up low-risk lint warnings

* chore: tighten utility types

* chore: preserve backend error causes

* chore: simplify command palette host state

* chore: remove unused frontend code

* chore: prune stale frontend state

* chore: trim unused navigation code

* chore: prune unused user settings props

* chore: trim unused sidebar state

* chore: remove stale host editor imports

* chore: tighten shared frontend types

* chore: narrow desktop helper types

* chore: type network topology data

* chore: type connection log errors

* chore: use typed tab context

* chore: type api client error metadata

* chore: tighten terminal config types

* chore: type host proxy chains

* chore: type host editor form data

* chore: use typed host viewer fields

* chore: format app builder patch script

* Fix client auth cache after upgrades

* chore: fix pr checks after dev merge

* fix: remove duplicate session-expired useEffect in FullScreenAppWrapper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: npm package warnings

* feat: reconnect after file manager disconnects

* feat: add docs button in api keys

* feat: change colors for server tunnels

* fix: fetch password from API for Copy Password button (#736)

* chore: update readme's

* feat: improve c2s UI in user profile

* feat: improve ssh key detection and move open file manager at path for terminal button

* fix: restore missing getHostPassword import in Tab.tsx (#737)

* fix: security related fixes

* feat: improve alert code

* Fix Electron clipboard handling

* fix: untranslated alert text

---------

Co-authored-by: Xenthys <x@dis.gg>
Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com>
Co-authored-by: suryacagur <suryacagur.dev@gmail.com>
Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Fuad <funtik1229@yandex.ru>
This commit is contained in:
Luke Gustafson
2026-05-06 15:12:07 -05:00
committed by GitHub
parent af9fc95b0e
commit 2768f11dfc
181 changed files with 15785 additions and 11276 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml}] [*.{js,cjs,mjs,jsx,ts,tsx,json,css,scss,md,yml,yaml}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
+3
View File
@@ -1,6 +1,8 @@
* text=auto eol=lf * text=auto eol=lf
*.js text eol=lf *.js text eol=lf
*.cjs text eol=lf
*.mjs text eol=lf
*.jsx text eol=lf *.jsx text eol=lf
*.ts text eol=lf *.ts text eol=lf
*.tsx text eol=lf *.tsx text eol=lf
@@ -29,3 +31,4 @@
*.woff2 binary *.woff2 binary
*.ttf binary *.ttf binary
*.eot binary *.eot binary
*.icns binary
+7 -7
View File
@@ -25,12 +25,12 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Determine tags - name: Determine tags
id: tags id: tags
@@ -57,7 +57,7 @@ jobs:
echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV
- name: Login to GHCR - name: Login to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: lukegus username: lukegus
@@ -65,18 +65,18 @@ jobs:
- name: Login to Docker Hub (prod only) - name: Login to Docker Hub (prod only)
if: ${{ github.event.inputs.build_type == 'Production' }} if: ${{ github.event.inputs.build_type == 'Production' }}
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: bugattiguy527 username: bugattiguy527
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push multi-arch image - name: Build and push multi-arch image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./docker/Dockerfile file: ./docker/Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
tags: ${{ env.ALL_TAGS }} tags: ${{ env.ALL_TAGS }}
build-args: | build-args: |
BUILDKIT_INLINE_CACHE=1 BUILDKIT_INLINE_CACHE=1
+4 -4
View File
@@ -40,7 +40,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
@@ -145,7 +145,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "npm" cache: "npm"
- name: Install system dependencies for AppImage - name: Install system dependencies for AppImage
@@ -368,7 +368,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
@@ -895,7 +895,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
+2 -4
View File
@@ -18,12 +18,10 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "20" node-version-file: ".nvmrc"
- name: Install dependencies - name: Install dependencies
run: | run: npm ci
rm -rf node_modules package-lock.json
npm install
- name: Run ESLint - name: Run ESLint
run: npx eslint . run: npx eslint .
+6 -5
View File
@@ -11,8 +11,7 @@ dist
dist-ssr dist-ssr
*.local *.local
.vscode/* .vscode/
!.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store
*.suo *.suo
@@ -20,12 +19,14 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/db/ /db/
/release/ /release/
/.claude/ /.claude/
/ssl/ /ssl/
.env /uploads/
/.mcp.json
/nul /nul
/.vscode/ .env
electron/build-info.cjs
/.mcp.json
/CLAUDE.md /CLAUDE.md
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+1 -1
View File
@@ -1 +1 @@
20 24
+1 -1
View File
@@ -1,5 +1,5 @@
cask "termix" do cask "termix" do
version "2.1.0" version "2.2.0"
sha256 "a897532f4002ed23b8b8f57312d0e0636e4b44729d0c42dad0030d80f43419cd" sha256 "a897532f4002ed23b8b8f57312d0e0636e4b44729d0c42dad0030d80f43419cd"
url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
+2 -1
View File
@@ -37,7 +37,7 @@ free and self-hosted alternative to Termius available for all platforms.
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components. - **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
- **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening - **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections - **SSH Tunnel Management** - Create and manage server-to-server SSH tunnels with automatic reconnection, health monitoring, and local, remote, or dynamic SOCKS forwarding. Desktop client-to-server tunnel settings are stored locally per desktop install, optional C2S preset snapshots can be saved to the server, renamed, loaded, or deleted when you want to move a local tunnel configuration between clients.
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. - **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them. - **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
@@ -46,6 +46,7 @@ free and self-hosted alternative to Termius available for all platforms.
- **RBAC** - Create roles and share hosts across users/roles - **RBAC** - Create roles and share hosts across users/roles
- **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. - **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
- **API Keys** - Create user-scoped API keys with expiration dates to be used for automation/CI
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects - **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between many different UI themes including light, dark, Dracula, etc. Use URL routes to open any connection in full-screen. - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between many different UI themes including light, dark, Dracula, etc. Use URL routes to open any connection in full-screen.
+14 -12
View File
@@ -1,12 +1,14 @@
# Stage 1: Install dependencies # Stage 1: Install dependencies
FROM node:22-slim AS deps FROM node:24-slim AS deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./ COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor
RUN npm ci --ignore-scripts --force && \ RUN npm ci --ignore-scripts && \
npm cache clean --force npm cache clean --force
# Stage 2: Build frontend # Stage 2: Build frontend
@@ -26,24 +28,26 @@ WORKDIR /app
COPY . . COPY . .
RUN npm rebuild better-sqlite3 --force RUN npm rebuild better-sqlite3
RUN npm run build:backend RUN npm run build:backend
# Stage 4: Production dependencies only # Stage 4: Production dependencies only
FROM node:22-slim AS production-deps FROM node:24-slim AS production-deps
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json ./ COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor
RUN npm ci --only=production --ignore-scripts --force && \ RUN npm ci --omit=dev --ignore-scripts && \
npm rebuild better-sqlite3 bcryptjs --force && \ npm rebuild better-sqlite3 bcryptjs && \
npm cache clean --force npm cache clean --force
# Stage 5: Final optimized image # Stage 5: Final optimized image
FROM node:22-slim FROM node:24-slim
WORKDIR /app WORKDIR /app
ENV DATA_DIR=/app/data \ ENV DATA_DIR=/app/data \
@@ -53,11 +57,9 @@ ENV DATA_DIR=/app/data \
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
update-ca-certificates && \ update-ca-certificates && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /app/nginx/logs /app/nginx/cache /app/nginx/client_body && \ mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
chown -R node:node /app && \ chown -R node:node /app /tmp/nginx && \
chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx && \ chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx
touch /app/nginx/nginx.conf && \
chown node:node /app/nginx/nginx.conf
COPY docker/nginx.conf /app/nginx/nginx.conf.template COPY docker/nginx.conf /app/nginx/nginx.conf.template
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template
+5 -4
View File
@@ -7,14 +7,14 @@ PGID=${PGID:-1000}
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
if [ "$PUID" = "0" ]; then if [ "$PUID" = "0" ]; then
echo "Running as root (PUID=0, PGID=$PGID)" echo "Running as root (PUID=0, PGID=$PGID)"
chown -R root:root /app/data /app/uploads /app/nginx 2>/dev/null || true chown -R root:root /app/data /app/uploads /tmp/nginx 2>/dev/null || true
else else
echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..." echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..."
groupmod -o -g "$PGID" node 2>/dev/null || true groupmod -o -g "$PGID" node 2>/dev/null || true
usermod -o -u "$PUID" node 2>/dev/null || true usermod -o -u "$PUID" node 2>/dev/null || true
chown -R node:node /app/data /app/uploads /app/nginx 2>/dev/null || true chown -R node:node /app/data /app/uploads /tmp/nginx 2>/dev/null || true
echo "User node is now UID: $PUID, GID: $PGID" echo "User node is now UID: $PUID, GID: $PGID"
@@ -38,7 +38,8 @@ else
NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template" NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template"
fi fi
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /app/nginx/nginx.conf mkdir -p /tmp/nginx
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf
mkdir -p /app/data /app/uploads /app/data/.opk mkdir -p /app/data /app/uploads /app/data/.opk
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true
@@ -132,7 +133,7 @@ EOF
fi fi
echo "Starting nginx..." echo "Starting nginx..."
nginx -c /app/nginx/nginx.conf nginx -c /tmp/nginx/nginx.conf
echo "Starting backend services..." echo "Starting backend services..."
cd /app cd /app
+58 -9
View File
@@ -1,7 +1,7 @@
worker_processes 1; worker_processes 1;
master_process off; master_process off;
pid /app/nginx/nginx.pid; pid /tmp/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn; error_log /tmp/nginx/error.log warn;
events { events {
worker_connections 1024; worker_connections 1024;
@@ -11,13 +11,13 @@ http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
access_log /app/nginx/logs/access.log; access_log /tmp/nginx/access.log;
client_body_temp_path /app/nginx/client_body; client_body_temp_path /tmp/nginx/client_body;
proxy_temp_path /app/nginx/proxy_temp; proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /app/nginx/fastcgi_temp; fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /app/nginx/uwsgi_temp; uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /app/nginx/scgi_temp; scgi_temp_path /tmp/nginx/scgi_temp;
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
@@ -65,16 +65,32 @@ http {
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
location = /sw.js {
root /app/html;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location = /manifest.json {
root /app/html;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
root /app/html; root /app/html;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -162,6 +178,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ { location ~ ^/terminal(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -337,6 +362,21 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
proxy_cache off;
}
location /host/file_manager/recent { location /host/file_manager/recent {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -455,6 +495,15 @@ http {
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
location ~ ^/(refresh|host-updated)$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/global-settings(/.*)?$ { location ~ ^/global-settings(/.*)?$ {
proxy_pass http://127.0.0.1:30005; proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1; proxy_http_version 1.1;
+58 -9
View File
@@ -1,7 +1,7 @@
worker_processes 1; worker_processes 1;
master_process off; master_process off;
pid /app/nginx/nginx.pid; pid /tmp/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn; error_log /tmp/nginx/error.log warn;
events { events {
worker_connections 1024; worker_connections 1024;
@@ -11,13 +11,13 @@ http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
access_log /app/nginx/logs/access.log; access_log /tmp/nginx/access.log;
client_body_temp_path /app/nginx/client_body; client_body_temp_path /tmp/nginx/client_body;
proxy_temp_path /app/nginx/proxy_temp; proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /app/nginx/fastcgi_temp; fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /app/nginx/uwsgi_temp; uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /app/nginx/scgi_temp; scgi_temp_path /tmp/nginx/scgi_temp;
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
@@ -54,16 +54,32 @@ http {
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
location = /sw.js {
root /app/html;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location = /manifest.json {
root /app/html;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
root /app/html; root /app/html;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404; try_files $uri =404;
} }
location / { location / {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -151,6 +167,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location ~ ^/c2s-tunnel-presets(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/terminal(/.*)?$ { location ~ ^/terminal(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -326,6 +351,21 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
proxy_cache off;
}
location /host/file_manager/recent { location /host/file_manager/recent {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -444,6 +484,15 @@ http {
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
location ~ ^/(refresh|host-updated)$ {
proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/global-settings(/.*)?$ { location ~ ^/global-settings(/.*)?$ {
proxy_pass http://127.0.0.1:30005; proxy_pass http://127.0.0.1:30005;
proxy_http_version 1.1; proxy_http_version 1.1;
+2 -2
View File
@@ -5,7 +5,8 @@
"directories": { "directories": {
"output": "release" "output": "release"
}, },
"asar": false, "asar": true,
"asarUnpack": ["dist/backend/**/*", "node_modules/**/*.node"],
"files": [ "files": [
"dist/**/*", "dist/**/*",
"electron/**/*", "electron/**/*",
@@ -130,7 +131,6 @@
"entitlementsInherit": "build/entitlements.mas.inherit.plist", "entitlementsInherit": "build/entitlements.mas.inherit.plist",
"hardenedRuntime": false, "hardenedRuntime": false,
"gatekeeperAssess": false, "gatekeeperAssess": false,
"asar": false,
"type": "distribution", "type": "distribution",
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"artifactName": "termix_macos_${arch}_mas.${ext}", "artifactName": "termix_macos_${arch}_mas.${ext}",
+1659 -58
View File
File diff suppressed because it is too large Load Diff
+33 -3
View File
@@ -1,5 +1,4 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
const { clipboard } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
@@ -10,15 +9,46 @@ contextBridge.exposeInMainWorld("electronAPI", {
getSetting: (key) => ipcRenderer.invoke("get-setting", key), getSetting: (key) => ipcRenderer.invoke("get-setting", key),
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
saveC2STunnelConfig: (config) =>
ipcRenderer.invoke("save-c2s-tunnel-config", config),
checkLocalPortAvailable: (host, port) =>
ipcRenderer.invoke("check-local-port-available", host, port),
getC2STunnelPresetDefaultName: () =>
ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
startC2STunnel: (tunnel, index) =>
ipcRenderer.invoke("start-c2s-tunnel", tunnel, index),
testC2STunnel: (tunnel, index) =>
ipcRenderer.invoke("test-c2s-tunnel", tunnel, index),
stopC2STunnel: (tunnelName) =>
ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
onC2STunnelStatuses: (callback) => {
const listener = (_event, statuses) => callback(statuses);
ipcRenderer.on("c2s-tunnel-statuses", listener);
return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
},
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
ipcRenderer.invoke(
"wait-session-cookie",
name,
targetUrl,
previousValue,
timeoutMs,
),
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
}); });
contextBridge.exposeInMainWorld("electronClipboard", { contextBridge.exposeInMainWorld("electronClipboard", {
writeText: (text) => clipboard.writeText(text), writeText: (text) => ipcRenderer.invoke("clipboard-write-text", text),
readText: () => clipboard.readText(), readText: () => ipcRenderer.invoke("clipboard-read-text"),
}); });
window.IS_ELECTRON = true; window.IS_ELECTRON = true;
+20 -2
View File
@@ -2,6 +2,7 @@ import js from "@eslint/js";
import globals from "globals"; import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from "eslint-plugin-react-refresh";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config"; import { globalIgnores } from "eslint/config";
@@ -12,19 +13,36 @@ export default tseslint.config([
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
plugins: {
"react-hooks": reactHooks,
"unused-imports": unusedImports,
},
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: { rules: {
"@typescript-eslint/no-unused-vars": "warn", "unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-expressions": "warn", "@typescript-eslint/no-unused-expressions": "warn",
"no-empty": "warn", "no-empty": "warn",
"no-control-regex": "off", "no-control-regex": "off",
"no-useless-assignment": "off",
"preserve-caught-error": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/rules-of-hooks": "error",
"react-refresh/only-export-components": "warn", "react-refresh/only-export-components": "warn",
}, },
}, },
+2700 -6011
View File
File diff suppressed because it is too large Load Diff
+112 -94
View File
@@ -1,15 +1,20 @@
{ {
"name": "termix", "name": "termix",
"private": true, "private": true,
"version": "2.1.0", "version": "2.2.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
"type": "module", "type": "module",
"engines": {
"node": ">=22.12.0",
"npm": ">=11"
},
"scripts": { "scripts": {
"clean": "npx prettier . --write",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"postinstall": "node scripts/patch-app-builder-lib.cjs",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
@@ -22,93 +27,94 @@
"generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js", "generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"", "electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
"electron:patch-builder": "node scripts/patch-app-builder-lib.cjs",
"electron:rebuild": "electron-rebuild -f -w better-sqlite3", "electron:rebuild": "electron-rebuild -f -w better-sqlite3",
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir", "build:win-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --dir",
"build:win-installer": "npm run build && npm run electron:rebuild && electron-builder --win --publish=never", "build:win-installer": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && npm run electron:rebuild && electron-builder --linux --dir", "build:linux-portable": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux --dir",
"build:linux-appimage": "npm run build && npm run electron:rebuild && electron-builder --linux AppImage", "build:linux-appimage": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux AppImage",
"build:linux-targz": "npm run build && npm run electron:rebuild && electron-builder --linux tar.gz", "build:linux-targz": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --linux tar.gz",
"build:mac": "npm run build && npm run electron:rebuild && electron-builder --mac --universal" "build:mac": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac --universal",
"build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never"
}, },
"dependencies": { "dependencies": {
"axios": "^1.10.0", "axios": "^1.15.2",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.2.0", "better-sqlite3": "^12.9.0",
"body-parser": "^1.20.2", "body-parser": "^2.2.2",
"chalk": "^4.1.2", "chalk": "^5.6.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.6",
"dotenv": "^17.2.0", "dotenv": "^17.4.2",
"drizzle-orm": "^0.44.3", "drizzle-orm": "^0.45.2",
"express": "^5.1.0", "express": "^5.2.1",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"jose": "^6.2.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jose": "^5.2.3", "jsonwebtoken": "^9.0.3",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"multer": "^2.0.2", "motion": "^12.38.0",
"nanoid": "^5.1.5", "multer": "^2.1.1",
"node-fetch": "^3.3.2", "nanoid": "^5.1.9",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react-is": "^19.2.5",
"socks": "^2.8.7", "socks": "^2.8.7",
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"ssh2": "^1.16.0", "ssh2": "^1.17.0",
"ws": "^8.18.3" "undici": "^7.0.0",
"ws": "^8.20.0"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.18.7", "@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.3.3", "@codemirror/commands": "^6.10.3",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.7.0",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.23.1", "@codemirror/view": "^6.41.1",
"@commitlint/cli": "^20.1.0", "@commitlint/cli": "^20.5.0",
"@commitlint/config-conventional": "^20.0.0", "@commitlint/config-conventional": "^20.5.0",
"@electron/notarize": "^2.5.0", "@deadendjs/swagger-jsdoc": "^8.1.2",
"@electron/rebuild": "^3.7.2", "@electron/notarize": "^3.1.1",
"@eslint/js": "^9.34.0", "@electron/rebuild": "^4.0.4",
"@hookform/resolvers": "^5.1.1", "@eslint/js": "^9.0.0",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.2.4",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/cytoscape": "^3.21.9", "@types/express": "^5.0.6",
"@types/express": "^5.0.3",
"@types/guacamole-common-js": "^1.5.5", "@types/guacamole-common-js": "^1.5.5",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/jszip": "^3.4.0", "@types/multer": "^2.1.0",
"@types/multer": "^2.0.0", "@types/node": "^24.12.2",
"@types/node": "^24.3.0", "@types/qrcode": "^1.5.6",
"@types/qrcode": "^1.5.5", "@types/react": "^19.2.14",
"@types/react": "^19.1.8", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.1.6",
"@types/react-grid-layout": "^1.3.6",
"@types/speakeasy": "^2.0.10", "@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.25.9",
"@uiw/codemirror-theme-github": "^4.25.4", "@uiw/codemirror-theme-github": "^4.25.9",
"@uiw/react-codemirror": "^4.24.1", "@uiw/react-codemirror": "^4.25.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^6.0.1",
"@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-unicode11": "^0.8.0",
@@ -118,53 +124,65 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.2",
"electron": "^38.0.0", "electron": "^41.3.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.8.1",
"eslint": "^9.34.0", "eslint": "^9.0.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.3.0", "eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0", "guacamole-common-js": "^1.5.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"i18next": "^25.4.2", "i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.1",
"lint-staged": "^16.2.3", "lint-staged": "^16.4.0",
"lucide-react": "^0.525.0", "lucide-react": "^1.11.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prettier": "3.6.2", "prettier": "3.8.3",
"react": "^19.1.0", "react": "^19.2.5",
"react-cytoscapejs": "^2.0.0", "react-cytoscapejs": "^2.0.0",
"react-dom": "^19.1.0", "react-dom": "^19.2.5",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.3",
"react-h5-audio-player": "^3.10.1", "react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.73.1",
"react-i18next": "^15.7.3", "react-i18next": "^17.0.4",
"react-icons": "^5.5.0", "react-icons": "^5.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.1.0", "react-pdf": "^10.4.1",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
"react-resizable-panels": "^3.0.3", "react-resizable-panels": "^4.10.0",
"react-simple-keyboard": "^3.8.120", "react-simple-keyboard": "^3.8.196",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^16.1.1",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^3.2.1", "recharts": "^3.8.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"swagger-jsdoc": "^6.2.8", "tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.3.1", "tailwindcss": "^4.2.4",
"tailwindcss": "^4.1.14", "typescript": "~6.0.3",
"typescript": "~5.9.2", "typescript-eslint": "^8.59.0",
"typescript-eslint": "^8.40.0", "vite": "^8.0.10",
"vite": "^7.1.5", "zod": "^4.3.6"
"zod": "^4.0.5"
}, },
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": [ "*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,jsx}": [
"prettier --write" "prettier --write"
], ],
"*.{json,css,md}": [ "*.{json,css,md}": [
"prettier --write" "prettier --write"
] ]
},
"overrides": {
"@electron/asar": "^4.2.0",
"@electron/get": "^5.0.0",
"dompurify": "^3.4.1",
"eslint-visitor-keys": "^4.2.1",
"prebuild-install": "npm:@mmomtchev/prebuild-install@1.0.2",
"rimraf": "file:vendor/rimraf-compat"
} }
} }
+3 -13
View File
@@ -1,8 +1,5 @@
const CACHE_NAME = "termix-v1"; const CACHE_NAME = "termix-static-v2";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/",
"/index.html",
"/manifest.json",
"/favicon.ico", "/favicon.ico",
"/icons/48x48.png", "/icons/48x48.png",
"/icons/128x128.png", "/icons/128x128.png",
@@ -66,18 +63,11 @@ self.addEventListener("fetch", (event) => {
} }
if (request.mode === "navigate") { if (request.mode === "navigate") {
event.respondWith( event.respondWith(fetch(request));
fetch(request).catch(() => {
return caches.match("/index.html");
}),
);
return; return;
} }
const isStaticAsset = STATIC_ASSETS.some((asset) => { const isStaticAsset = STATIC_ASSETS.some((asset) => url.pathname === asset);
if (asset === "/") return url.pathname === "/";
return url.pathname === asset || url.pathname.startsWith("/assets/");
});
if (!isStaticAsset) { if (!isStaticAsset) {
return; return;
+4 -19
View File
@@ -1,20 +1,7 @@
# إحصائيات المستودع # إحصائيات المستودع
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · 🇸🇦 العربية · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
إذا كنت ترغب في ذلك، يمكنك دعم المشروع هنا!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# نظرة عامة # نظرة عامة
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى. - **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك سمات الطرفية الشائعة والخطوط والمكونات الأخرى.
- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة. - **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة.
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r. - **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH بين الخوادم مع إعادة الاتصال التلقائي ومراقبة الحالة وإعادة التوجيه المحلي أو البعيد أو SOCKS الديناميكي. يتم تخزين إعدادات نفق العميل-المكتبي إلى السيرفر محلياً لكل تثبيت مكتبي؛ يمكن حفظ لقطات C2S الاختيارية على الخادم وإعادة تسميتها وتحميلها أو حذفها لنقل تكوين النفق المحلي بين العملاء.
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo. - **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo.
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها. - **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها.
- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH. - **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH.
@@ -59,6 +43,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار. - **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار.
- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً. - **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC (مع التحكم في الوصول) و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً.
- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات. - **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات.
- **مفاتيح API** - إنشاء مفاتيح API محددة النطاق للمستخدم مع تواريخ انتهاء صلاحية للاستخدام في الأتمتة/CI.
- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات. - **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات.
- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS. - **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS.
- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين العديد من سمات واجهة المستخدم بما في ذلك الفاتح والداكن و Dracula وغيرها. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة. - **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين العديد من سمات واجهة المستخدم بما في ذلك الفاتح والداكن و Dracula وغيرها. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# 仓库统计 # 仓库统计
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · 🇨🇳 中文 · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文 ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
如果你愿意,可以在这里支持这个项目!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# 概览 # 概览
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **SSH 终端访问** - 功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。 - **SSH 终端访问** - 功能齐全的终端,支持分屏(最多 4 个面板),并配有类似浏览器的标签系统。包括对自定义终端的支持,如常用的终端主题、字体和其他组件。
- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。 - **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能。
- **SSH 隧道管理** - 创建和管理具有自动重连和健康监测功能的 SSH 隧道,支持 -l 或 -r 连接 - **SSH 隧道管理** - 创建和管理具有自动重连和健康监测功能的服务器间 SSH 隧道,支持本地、远程或动态 SOCKS 转发。桌面客户端到服务器的隧道设置按桌面安装本地存储,可选的 C2S 预设快照可保存到服务器、重命名、加载或删除,以便在客户端之间迁移本地隧道配置
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。 - **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。支持通过 sudo 无缝上传、下载、重命名、删除和移动文件。
- **Docker 管理** - 启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。 - **Docker 管理** - 启动、停止、暂停、移除容器。查看容器统计信息。通过 docker exec 终端控制容器。它的初衷不是取代 Portainer 或 Dockge,而是为了比直接创建容器更简单地管理它们。
- **SSH 主机管理器** - 通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。 - **SSH 主机管理器** - 通过标签和文件夹保存、组织和管理您的 SSH 连接,轻松保存可重用的登录信息,并能自动化部署 SSH 密钥。
@@ -59,6 +43,7 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- **RBAC** - 创建角色并在用户/角色之间共享主机。 - **RBAC** - 创建角色并在用户/角色之间共享主机。
- **用户认证** - 安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。 - **用户认证** - 安全的用户管理,具有管理员控制、OIDC(带访问控制)和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地账户链接在一起。
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。 - **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多。
- **API 密钥** - 创建带有到期日期的用户范围 API 密钥,用于自动化/CI。
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据。 - **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据。
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向。 - **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向。
- **现代 UI** - 使用 React、Tailwind CSS 和 Shadcn 构建的整洁的桌面/移动友好界面。有多种 UI 主题可选,包括浅色、深色、Dracula 等。使用 URL 路由全屏打开任何连接。 - **现代 UI** - 使用 React、Tailwind CSS 和 Shadcn 构建的整洁的桌面/移动友好界面。有多种 UI 主题可选,包括浅色、深色、Dracula 等。使用 URL 路由全屏打开任何连接。
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# Repo-Statistiken # Repo-Statistiken
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · 🇩🇪 Deutsch · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Wenn Sie möchten, können Sie das Projekt hier unterstützen!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Überblick # Überblick
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten. - **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten.
- **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen. - **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen.
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen. - **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie Server-zu-Server-SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie lokaler, entfernter oder dynamischer SOCKS-Weiterleitung. Desktop-Client-zu-Server-Tunneleinstellungen werden lokal pro Desktop-Installation gespeichert, optionale C2S-Preset-Snapshots können auf dem Server gespeichert, umbenannt, geladen oder gelöscht werden, um eine lokale Tunnelkonfiguration zwischen Clients zu übertragen.
- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung. - **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen. - **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
- **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren. - **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren.
@@ -59,6 +43,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen. - **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen.
- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen. - **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- (mit Zugriffskontrolle) sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen.
- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security). - **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security).
- **API-Schlüssel** - Erstellen Sie benutzerbezogene API-Schlüssel mit Ablaufdaten zur Verwendung für Automatisierung/CI.
- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren. - **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren.
- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen. - **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen.
- **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen vielen verschiedenen UI-Themes einschließlich Hell, Dunkel, Dracula usw. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen. - **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen vielen verschiedenen UI-Themes einschließlich Hell, Dunkel, Dracula usw. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# Estadísticas del Repositorio # Estadísticas del Repositorio
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · 🇪🇸 Español · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Si lo desea, puede apoyar el proyecto aquí.\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Descripción General # Descripción General
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes. - **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes.
- **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida. - **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida.
- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r. - **Gestión de Túneles SSH** - Cree y gestione túneles SSH de servidor a servidor con reconexión automática, monitoreo de estado y reenvío local, remoto o dinámico SOCKS. La configuración de túneles de cliente de escritorio a servidor se almacena localmente por instalación de escritorio; los snapshots de presets C2S opcionales pueden guardarse en el servidor, renombrarse, cargarse o eliminarse para mover una configuración de túnel local entre clientes.
- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo. - **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos. - **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal docker exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
- **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH. - **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH.
@@ -59,6 +43,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
- **RBAC** - Cree roles y comparta hosts entre usuarios/roles. - **RBAC** - Cree roles y comparta hosts entre usuarios/roles.
- **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí. - **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC (con control de acceso) y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí.
- **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información. - **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información.
- **Claves API** - Cree claves API con ámbito de usuario y fechas de vencimiento para usar en automatización/CI.
- **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos. - **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos.
- **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS. - **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS.
- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre muchos temas de UI diferentes, incluyendo claro, oscuro, Dracula, etc. Use rutas URL para abrir cualquier conexión en pantalla completa. - **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre muchos temas de UI diferentes, incluyendo claro, oscuro, Dracula, etc. Use rutas URL para abrir cualquier conexión en pantalla completa.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# Statistiques du dépôt # Statistiques du dépôt
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · 🇫🇷 Français · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Si vous le souhaitez, vous pouvez soutenir le projet ici !\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Présentation # Présentation
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants. - **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants.
- **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé. - **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé.
- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r. - **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH de serveur à serveur avec reconnexion automatique, surveillance de l'état et transfert local, distant ou SOCKS dynamique. Les paramètres de tunnel client-bureau-vers-serveur sont stockés localement par installation bureau ; des instantanés de préréglages C2S optionnels peuvent être sauvegardés sur le serveur, renommés, chargés ou supprimés pour déplacer une configuration de tunnel locale entre clients.
- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo. - **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo.
- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer. - **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer.
- **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH. - **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH.
@@ -59,6 +43,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
- **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles. - **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles.
- **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC (avec contrôle d'accès) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble. - **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC (avec contrôle d'accès) et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble.
- **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails. - **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails.
- **Clés API** - Créez des clés API à portée utilisateur avec des dates d'expiration pour une utilisation en automatisation/CI.
- **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers. - **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers.
- **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS. - **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS.
- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez parmi de nombreux thèmes d'interface utilisateur, notamment clair, sombre, Dracula, etc. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran. - **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez parmi de nombreux thèmes d'interface utilisateur, notamment clair, sombre, Dracula, etc. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# रिपॉजिटरी आँकड़े # रिपॉजिटरी आँकड़े
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · 🇮🇳 हिन्दी · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
यदि आप चाहें, तो आप यहाँ प्रोजेक्ट को सपोर्ट कर सकते हैं!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# अवलोकन # अवलोकन
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है। - **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है।
- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ। - **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ।
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ - **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन, हेल्थ मॉनिटरिंग और लोकल, रिमोट या डायनेमिक SOCKS फॉरवर्डिंग के साथ सर्वर-टु-सर्वर SSH टनल बनाएँ और प्रबंधित करें। डेस्कटॉप क्लाइंट-टु-सर्वर टनल सेटिंग्स प्रत्येक डेस्कटॉप इंस्टॉल में स्थानीय रूप से संग्रहीत होती हैं; वैकल्पिक C2S प्रीसेट स्नैपशॉट सर्वर पर सेव, रीनेम, लोड या डिलीट किए जा सकते हैं
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें। - **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें।
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है। - **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है।
- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें। - **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें।
@@ -59,6 +43,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें। - **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें।
- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें। - **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC (एक्सेस कंट्रोल के साथ) और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें।
- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें। - **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें।
- **API कुंजियाँ** - ऑटोमेशन/CI के लिए उपयोग हेतु समाप्ति तिथियों के साथ उपयोगकर्ता-स्कोप्ड API कुंजियाँ बनाएँ।
- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें। - **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें।
- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन। - **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन।
- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। लाइट, डार्क, ड्रैकुला आदि सहित कई अलग-अलग UI थीम के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें। - **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। लाइट, डार्क, ड्रैकुला आदि सहित कई अलग-अलग UI थीम के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें।
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# Statistiche Repo # Statistiche Repo
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · 🇮🇹 Italiano
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Se lo desideri, puoi supportare il progetto qui!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Panoramica # Panoramica
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
- **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni. - **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni.
- **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso. - **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso.
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r. - **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH da server a server con riconnessione automatica, monitoraggio dello stato e inoltro locale, remoto o SOCKS dinamico. Le impostazioni del tunnel da client desktop a server sono archiviate localmente per ogni installazione desktop; gli snapshot di preset C2S opzionali possono essere salvati sul server, rinominati, caricati o eliminati per spostare una configurazione di tunnel locale tra i client.
- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo. - **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione. - **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
- **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH. - **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH.
@@ -59,6 +43,7 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli. - **RBAC** - Crea ruoli e condividi host tra utenti/ruoli.
- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro. - **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC (con controllo degli accessi) e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni. - **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
- **Chiavi API** - Crea chiavi API con ambito utente e date di scadenza da utilizzare per automazione/CI.
- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file. - **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file.
- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS. - **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS.
- **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra molti temi UI diversi, inclusi chiaro, scuro, Dracula, ecc. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero. - **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra molti temi UI diversi, inclusi chiaro, scuro, Dracula, ecc. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# リポジトリ統計 # リポジトリ統計
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · 🇯🇵 日本語 · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語 ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
プロジェクトを支援していただける方はこちらからどうぞ!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# 概要 # 概要
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。 - **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応しています。
- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。 - **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応しています。
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応しています。 - **SSHトンネル管理** - 自動再接続とヘルスモニタリング、ローカル・リモート・ダイナミックSOCKSフォワーディングを備えたサーバー間SSHトンネルの作成・管理が可能です。デスクトップクライアント対サーバーのトンネル設定はデスクトップインストールごとにローカルに保存され、オプションのC2Sプリセットスナップショットをサーバーに保存・名前変更・読み込み・削除してクライアント間でローカルトンネル設定を移動できます。
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。 - **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理できます。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行できます。
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。 - **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易的な管理を目的としています。
- **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。 - **SSHホストマネージャー** - タグやフォルダでSSH接続を保存、整理、管理し、再利用可能なログイン情報を簡単に保存しながらSSHキーのデプロイを自動化できます。
@@ -59,6 +43,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
- **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有できます。 - **RBAC** - ロールを作成し、ユーザー/ロール間でホストを共有できます。
- **ユーザー認証** - 管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。 - **ユーザー認証** - 管理者コントロールとOIDC(アクセス制御付き)および2FA(TOTP)対応による安全なユーザー管理。すべてのプラットフォームでアクティブなユーザーセッションを表示し、権限を取り消し可能。OIDC/ローカルアカウントの連携が可能です。
- **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。 - **データベース暗号化** - バックエンドは暗号化されたSQLiteデータベースファイルとして保存されます。詳細は[ドキュメント](https://docs.termix.site/security)をご覧ください。
- **APIキー** - 自動化/CI用に有効期限付きのユーザースコープAPIキーを作成できます。
- **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です。 - **データのエクスポート/インポート** - SSHホスト、認証情報、ファイルマネージャーデータのエクスポートとインポートが可能です。
- **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です。 - **自動SSL設定** - HTTPSリダイレクト付きの組み込みSSL証明書生成・管理が可能です。
- **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ライト、ダーク、Draculaなど、多くの異なるUIテーマから選択可能。URLルートで任意の接続をフルスクリーンで開くことができます。 - **モダンUI** - React、Tailwind CSS、Shadcnで構築された、デスクトップ/モバイル対応のクリーンなインターフェース。ライト、ダーク、Draculaなど、多くの異なるUIテーマから選択可能。URLルートで任意の接続をフルスクリーンで開くことができます。
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+4 -19
View File
@@ -1,20 +1,7 @@
# 리포지토리 통계 # 리포지토리 통계
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · 🇰🇷 한국어 · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어 ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
프로젝트를 후원하고 싶으시다면 여기에서 지원해 주세요!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# 개요 # 개요
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원. - **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원.
- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원. - **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원.
- **SSH 터널 관리** - 자동 재연결 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원. - **SSH 터널 관리** - 자동 재연결, 상태 모니터링, 로컬·원격·동적 SOCKS 포워딩을 지원하는 서버 간 SSH 터널 생성 및 관리. 데스크톱 클라이언트-서버 터널 설정은 데스크톱 설치별로 로컬에 저장되며, 선택적 C2S 사전 설정 스냅샷을 서버에 저장·이름 변경·불러오기·삭제하여 클라이언트 간 로컬 터널 구성을 이동할 수 있습니다.
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행. - **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행.
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다. - **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다.
- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화. - **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화.
@@ -59,6 +43,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유. - **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유.
- **사용자 인증** - 관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동. - **사용자 인증** - 관리자 제어와 OIDC(액세스 제어 포함) 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동.
- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요. - **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요.
- **API 키** - 자동화/CI에 사용할 만료일이 있는 사용자 범위 API 키 생성.
- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기. - **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기.
- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리. - **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리.
- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 라이트, 다크, 드라큘라 등 다양한 UI 테마 선택 가능. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능. - **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 라이트, 다크, 드라큘라 등 다양한 UI 테마 선택 가능. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
+8 -23
View File
@@ -1,20 +1,7 @@
# Estatísticas do Repositório # Estatísticas do Repositório
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · 🇧🇷 Português · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Se desejar, você pode apoiar o projeto aqui!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Visão Geral # Visão Geral
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes. - **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes.
- **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida - **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida
- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r - **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH de servidor para servidor com reconexão automática, monitoramento de saúde e encaminhamento local, remoto ou SOCKS dinâmico. As configurações de túnel de cliente desktop para servidor são armazenadas localmente por instalação de desktop; snapshots de predefinições C2S opcionais podem ser salvos no servidor, renomeados, carregados ou excluídos para mover uma configuração de túnel local entre clientes.
- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo. - **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los. - **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
- **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH - **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH
@@ -59,6 +43,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
- **RBAC** - Crie funções e compartilhe hosts entre usuários/funções - **RBAC** - Crie funções e compartilhe hosts entre usuários/funções
- **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si. - **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC (com controle de acesso) e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si.
- **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações. - **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações.
- **Chaves de API** - Crie chaves de API com escopo de usuário e datas de expiração para uso em automação/CI.
- **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos - **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos
- **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS - **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS
- **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre muitos temas de interface diferentes, incluindo claro, escuro, Drácula, etc. Use rotas de URL para abrir qualquer conexão em tela cheia. - **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre muitos temas de interface diferentes, incluindo claro, escuro, Drácula, etc. Use rotas de URL para abrir qualquer conexão em tela cheia.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -181,7 +166,7 @@ Por favor, seja o mais detalhado possível no seu relato, preferencialmente escr
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)
<p align="center"> <p align="center">
<img src="../repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/> <img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p> </p>
@@ -202,12 +187,12 @@ Por favor, seja o mais detalhado possível no seu relato, preferencialmente escr
<p align="center"> <p align="center">
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/> <img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/> <img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
</p> </p>
<p align="center"> <p align="center">
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/> <img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/> <img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
</p> </p>
Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades. Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades.
+8 -23
View File
@@ -1,20 +1,7 @@
# Статистика репозитория # Статистика репозитория
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · 🇷🇺 Русский · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Если хотите, вы можете поддержать проект здесь!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Обзор # Обзор
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix — это платформа для управления сервера
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты. - **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты.
- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана - **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r - **Управление SSH-туннелями** — Создание и управление межсерверными SSH-туннелями с автоматическим переподключением, мониторингом состояния и локальной, удалённой или динамической SOCKS-переадресацией. Настройки туннелей «десктопный клиент — сервер» хранятся локально для каждой установки; опциональные снимки C2S-пресетов можно сохранять на сервере, переименовывать, загружать или удалять для переноса конфигурации между клиентами.
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo. - **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo.
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием. - **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием.
- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей - **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей
@@ -59,6 +43,7 @@ Termix — это платформа для управления сервера
- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей - **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей
- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов. - **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC (с контролем доступа) и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов.
- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security) - **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security)
- **API-ключи** — Создание API-ключей с областью видимости пользователя и сроками действия для использования в автоматизации/CI.
- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера - **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера
- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS - **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS
- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме. - **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между множеством различных тем интерфейса, включая светлую, тёмную, Dracula и т. д. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -181,7 +166,7 @@ networks:
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)
<p align="center"> <p align="center">
<img src="../repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/> <img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p> </p>
@@ -202,12 +187,12 @@ networks:
<p align="center"> <p align="center">
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/> <img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/> <img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
</p> </p>
<p align="center"> <p align="center">
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/> <img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/> <img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
</p> </p>
Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность. Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.
+8 -23
View File
@@ -1,20 +1,7 @@
# Repo İstatistikleri # Repo İstatistikleri
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · 🇹🇷 Türkçe · <a href="README-VI.md">🇻🇳 Tiếng Việt</a> · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe ·
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Genel Bakış # Genel Bakış
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir. - **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir.
- **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği - **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği
- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle - **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma, sağlık izleme ve yerel, uzak veya dinamik SOCKS yönlendirme desteğiyle sunucular arası SSH tünelleri oluşturun ve yönetin. Masaüstü istemci-sunucu tünel ayarları her masaüstü kurulumu için yerel olarak depolanır; isteğe bağlı C2S hazır ayar anlık görüntüleri sunucuya kaydedilebilir, yeniden adlandırılabilir, yüklenebilir veya silinebilir.
- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın. - **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır. - **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
- **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin - **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin
@@ -59,6 +43,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın - **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın
- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC (erişim kontrollü) ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın. - **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC (erişim kontrollü) ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın.
- **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın. - **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın.
- **API Anahtarları** - Otomasyon/CI için kullanılmak üzere son kullanma tarihleriyle kullanıcı kapsamlı API anahtarları oluşturun.
- **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın - **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın
- **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi - **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi
- **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Işık, karanlık, Dracula vb. dahil olmak üzere birçok farklı UI teması arasından seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın. - **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Işık, karanlık, Dracula vb. dahil olmak üzere birçok farklı UI teması arasından seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -181,7 +166,7 @@ Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)
<p align="center"> <p align="center">
<img src="../repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/> <img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p> </p>
@@ -202,12 +187,12 @@ Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce
<p align="center"> <p align="center">
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/> <img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/> <img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
</p> </p>
<p align="center"> <p align="center">
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/> <img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/> <img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
</p> </p>
Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir. Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir.
+8 -23
View File
@@ -1,20 +1,7 @@
# Thống Kê Repo # Thống Kê Repo
<p align="center"> <p align="center">
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> · <a href="../README.md">🇺🇸 English</a> · <a href="README-CN.md">🇨🇳 中文</a> · <a href="README-JA.md">🇯🇵 日本語</a> · <a href="README-KO.md">🇰🇷 한국어</a> · <a href="README-FR.md">🇫🇷 Français</a> · <a href="README-DE.md">🇩🇪 Deutsch</a> · <a href="README-ES.md">🇪🇸 Español</a> · <a href="README-PT.md">🇧🇷 Português</a> · <a href="README-RU.md">🇷🇺 Русский</a> · <a href="README-AR.md">🇸🇦 العربية</a> · <a href="README-HI.md">🇮🇳 हिन्दी</a> · <a href="README-TR.md">🇹🇷 Türkçe</a> · 🇻🇳 Tiếng Việt · <a href="README-IT.md">🇮🇹 Italiano</a>
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a> ·
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
<img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt ·
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
</p> </p>
![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars) ![GitHub Repo stars](https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars)
@@ -34,9 +21,6 @@
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a> <img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p> </p>
Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Tổng Quan # Tổng Quan
<p align="center"> <p align="center">
@@ -50,7 +34,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác. - **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác.
- **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình - **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình
- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r - **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH giữa các máy chủ với tự động kết nối lại, giám sát sức khỏe và chuyển tiếp cục bộ, từ xa hoặc SOCKS động. Cài đặt đường hầm từ máy khách desktop đến máy chủ được lưu trữ cục bộ cho mỗi bản cài đặt desktop; các snapshot C2S preset tùy chọn có thể được lưu trên máy chủ, đổi tên, tải hoặc xóa để di chuyển cấu hình đường hầm cục bộ giữa các máy khách.
- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo. - **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng. - **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
- **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH - **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH
@@ -59,6 +43,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
- **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò - **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò
- **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC (có kiểm soát truy cập) và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau. - **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC (có kiểm soát truy cập) và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau.
- **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm. - **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm.
- **Khóa API** - Tạo khóa API theo phạm vi người dùng với ngày hết hạn để sử dụng cho tự động hóa/CI.
- **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp - **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp
- **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS - **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS
- **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa nhiều chủ đề UI khác nhau bao gồm sáng, tối, Dracula, v.v. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình. - **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa nhiều chủ đề UI khác nhau bao gồm sáng, tối, Dracula, v.v. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình.
@@ -122,7 +107,7 @@ services:
- termix-net - termix-net
guacd: guacd:
image: guacamole/guacd:latest image: guacamole/guacd:1.6.0
container_name: guacd container_name: guacd
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -181,7 +166,7 @@ Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết b
[![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos) [![YouTube](../repo-images/YouTube.jpg)](https://www.youtube.com/@TermixSSH/videos)
<p align="center"> <p align="center">
<img src="../repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
<img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/> <img src="../repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p> </p>
@@ -202,12 +187,12 @@ Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết b
<p align="center"> <p align="center">
<img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/> <img src="../repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
<img src="../repo-images/Image 10.png" width="400" alt="Termix Demo 10"/> <img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
</p> </p>
<p align="center"> <p align="center">
<img src="../repo-images/Image 11.png" width="400" alt="Termix Demo 11"/> <img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
<img src="../repo-images/Image 12.png" width="400" alt="Termix Demo 12"/> <img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
</p> </p>
Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng. Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng.
+197
View File
@@ -0,0 +1,197 @@
const fs = require("node:fs");
const path = require("node:path");
const collectorPath = path.join(
__dirname,
"..",
"node_modules",
"app-builder-lib",
"out",
"node-module-collector",
"nodeModulesCollector.js",
);
const appFileCopierPath = path.join(
__dirname,
"..",
"node_modules",
"app-builder-lib",
"out",
"util",
"appFileCopier.js",
);
const moduleManagerPath = path.join(
__dirname,
"..",
"node_modules",
"app-builder-lib",
"out",
"node-module-collector",
"moduleManager.js",
);
function patchFile(filePath, replacements) {
if (!fs.existsSync(filePath)) {
return;
}
let source = fs.readFileSync(filePath, "utf8");
let changed = false;
for (const { original, patched, name, alreadyPatched = [] } of replacements) {
if (
source.includes(patched) ||
alreadyPatched.some((marker) => source.includes(marker))
) {
continue;
}
if (!source.includes(original)) {
console.warn(
`app-builder-lib patch "${name}" was not applied; expected source was not found.`,
);
continue;
}
source = source.replace(original, patched);
changed = true;
}
if (changed) {
fs.writeFileSync(filePath, source);
}
}
patchFile(collectorPath, [
{
name: "node module collector spawn shell",
original: ` shell: true, // \`true\`\` is now required: https://github.com/electron-userland/electron-builder/issues/9488`,
patched: ` shell: false, // Avoid Node DEP0190; .cmd files are wrapped through cmd.exe above.`,
},
{
name: "node module collector output flush",
alreadyPatched: [
` outStream.end();
}`,
],
original: ` outStream.close();
// https://github.com/npm/npm/issues/17624
const shouldIgnore = code === 1 && "npm" === execName.toLowerCase() && args.includes("list");
if (shouldIgnore) {
builder_util_1.log.debug(null, "\`npm list\` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details.");
}
if (stderr.length > 0) {
builder_util_1.log.debug({ stderr }, "note: there was node module collector output on stderr");
this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_COLLECTOR_OUTPUT].push(stderr);
}
const shouldResolve = code === 0 || shouldIgnore;
return shouldResolve ? resolve() : reject(new Error(\`Node module collector process exited with code \${code}:\\n\${stderr}\`));`,
patched: ` const finish = () => {
// https://github.com/npm/npm/issues/17624
const shouldIgnore = code === 1 && "npm" === execName.toLowerCase() && args.includes("list");
if (shouldIgnore) {
builder_util_1.log.debug(null, "\`npm list\` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details.");
}
if (stderr.length > 0) {
builder_util_1.log.debug({ stderr }, "note: there was node module collector output on stderr");
this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_COLLECTOR_OUTPUT].push(stderr);
}
const shouldResolve = code === 0 || shouldIgnore;
return shouldResolve ? resolve() : reject(new Error(\`Node module collector process exited with code \${code}:\\n\${stderr}\`));
};
if (outStream.writableFinished) {
finish();
}
else {
outStream.once("finish", finish);
outStream.end();
}`,
},
{
name: "node module collector finish guard",
original: ` if (outStream.writableFinished || outStream.closed) {`,
patched: ` if (outStream.writableFinished) {`,
},
]);
patchFile(moduleManagerPath, [
{
name: "node module collector npm alias resolution",
original: ` semverSatisfies(found, range) {
if ((0, builder_util_1.isEmptyOrSpaces)(range) || range === "*") {`,
patched: ` semverSatisfies(found, range, packageNameMatches = true) {
if (!packageNameMatches) {
return true;
}
if ((0, builder_util_1.isEmptyOrSpaces)(range) || range === "*") {`,
},
{
name: "node module collector direct alias match",
original: ` if (json && this.semverSatisfies(json.version, requiredRange)) {
return { packageDir: path.dirname(direct), packageJson: json };
}`,
patched: ` if (json && this.semverSatisfies(json.version, requiredRange, json.name === pkgName)) {
return { packageDir: path.dirname(direct), packageJson: json };
}`,
},
{
name: "node module collector alias match",
original: ` if (json && this.semverSatisfies(json.version, requiredRange)) {
return { packageDir: path.dirname(candidate), packageJson: json };
}`,
patched: ` if (json && this.semverSatisfies(json.version, requiredRange, json.name === pkgName)) {
return { packageDir: path.dirname(candidate), packageJson: json };
}`,
},
{
name: "node module collector scoped alias match",
original: ` if (json && this.semverSatisfies(json.version, requiredRange)) {
return { packageDir: path.dirname(candidatePkgJson), packageJson: json };
}`,
patched: ` if (json && this.semverSatisfies(json.version, requiredRange, json.name === pkgName)) {
return { packageDir: path.dirname(candidatePkgJson), packageJson: json };
}`,
},
{
name: "node module collector nested alias match",
original: ` if (json && this.semverSatisfies(json.version, requiredRange)) {
return { packageDir: path.dirname(candidatePkgJson), packageJson: json };
}`,
patched: ` if (json && this.semverSatisfies(json.version, requiredRange, json.name === pkgName)) {
return { packageDir: path.dirname(candidatePkgJson), packageJson: json };
}`,
},
{
name: "node module collector nested direct alias match",
original: ` if (json && this.semverSatisfies(json.version, requiredRange)) {
return { packageDir: path.dirname(candidateDirect), packageJson: json };
}`,
patched: ` if (json && this.semverSatisfies(json.version, requiredRange, json.name === pkgName)) {
return { packageDir: path.dirname(candidateDirect), packageJson: json };
}`,
},
]);
patchFile(appFileCopierPath, [
{
name: "node module collector fallback",
original: ` const collector = (0, node_module_collector_1.getCollectorByPackageManager)(pm, dir, tempDirManager);
deps = await collector.getNodeModules({ packageName: packager.metadata.name });
if (deps.nodeModules.length > 0) {`,
patched: ` const collector = (0, node_module_collector_1.getCollectorByPackageManager)(pm, dir, tempDirManager);
try {
deps = await collector.getNodeModules({ packageName: packager.metadata.name });
}
catch (error) {
const isLastSearchDirectory = searchDirectories.indexOf(dir) >= searchDirectories.length - 1;
const isLastPackageManager = pmApproaches.indexOf(pm) >= pmApproaches.length - 1;
if (isLastSearchDirectory && isLastPackageManager) {
throw error;
}
builder_util_1.log.warn({ pm, searchDir: dir, error: error instanceof Error ? error.message : String(error) }, "node modules collection failed, trying fallback");
continue;
}
if (deps.nodeModules.length > 0) {`,
},
]);
+24
View File
@@ -0,0 +1,24 @@
const fs = require("fs");
const path = require("path");
const outputPath = path.join(__dirname, "..", "electron", "build-info.cjs");
const rawBuildTimestamp =
process.env.TERMIX_BUILD_TIMESTAMP || process.env.BUILD_TIMESTAMP;
const parsedBuildTimestamp = rawBuildTimestamp
? Number(rawBuildTimestamp)
: NaN;
const buildTimestamp = Number.isInteger(parsedBuildTimestamp)
? parsedBuildTimestamp
: Math.floor(Date.now() / 1000);
const buildInfo = {
buildTimestamp,
generatedAt: new Date().toISOString(),
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(
outputPath,
`module.exports = ${JSON.stringify(buildInfo, null, 2)};\n`,
);
console.log(`Wrote Electron build info: ${buildTimestamp}`);
+71 -8
View File
@@ -8,12 +8,12 @@ import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js"; import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js"; import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js"; import snippetsRoutes from "./routes/snippets.js";
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js"; import terminalRoutes from "./routes/terminal.js";
import guacamoleRoutes from "../guacamole/routes.js"; import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js"; import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js"; import rbacRoutes from "./routes/rbac.js";
import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCorsMiddleware } from "../utils/cors-config.js";
import fetch from "node-fetch";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
@@ -118,6 +118,31 @@ class GitHubCache {
const githubCache = new GitHubCache(); const githubCache = new GitHubCache();
function parseSemver(
version: string | undefined,
): [number, number, number] | null {
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
}
function compareSemver(
a: string | undefined,
b: string | undefined,
): number | null {
const parsedA = parseSemver(a);
const parsedB = parseSemver(b);
if (!parsedA || !parsedB) return null;
for (let i = 0; i < 3; i += 1) {
if (parsedA[i] > parsedB[i]) return 1;
if (parsedA[i] < parsedB[i]) return -1;
}
return 0;
}
const GITHUB_API_BASE = "https://api.github.com"; const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "Termix-SSH"; const REPO_OWNER = "Termix-SSH";
const REPO_NAME = "Termix"; const REPO_NAME = "Termix";
@@ -143,7 +168,7 @@ async function fetchGitHubAPI<T>(
"User-Agent": "TermixUpdateChecker/1.0", "User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
}, },
agent: getProxyAgent(url), dispatcher: getProxyAgent(url),
}); });
if (!response.ok) { if (!response.ok) {
@@ -299,12 +324,19 @@ app.get("/version", authenticateJWT, async (req, res) => {
return res.status(401).send("Remote Version Not Found"); return res.status(401).send("Remote Version Not Found");
} }
const isUpToDate = localVersion === remoteVersion; const versionComparison = compareSemver(localVersion, remoteVersion);
const status =
versionComparison === null || versionComparison === 0
? "up_to_date"
: versionComparison > 0
? "beta"
: "requires_update";
const response = { const response = {
status: isUpToDate ? "up_to_date" : "requires_update", status,
localVersion: localVersion, localVersion: localVersion,
version: remoteVersion, version: remoteVersion,
remoteVersion: remoteVersion,
latest_release: { latest_release: {
tag_name: releaseData.data.tag_name, tag_name: releaseData.data.tag_name,
name: releaseData.data.name, name: releaseData.data.name,
@@ -624,7 +656,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
operation: "export_temp_dir_error", operation: "export_temp_dir_error",
tempDir, tempDir,
}); });
throw new Error(`Failed to create temp directory: ${dirError.message}`); throw new Error(`Failed to create temp directory: ${dirError.message}`, {
cause: dirError,
});
} }
const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
@@ -1161,7 +1195,7 @@ app.post(
mimetype: req.file.mimetype, mimetype: req.file.mimetype,
}); });
let userDataKey = DataCrypto.getUserDataKey(userId); const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) { if (!userDataKey) {
throw new Error("User data not unlocked"); throw new Error("User data not unlocked");
} }
@@ -1719,6 +1753,7 @@ app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes); app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes); app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes); app.use("/snippets", snippetsRoutes);
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes); app.use("/terminal", terminalRoutes);
app.use("/guacamole", guacamoleRoutes); app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes); app.use("/network-topology", networkTopologyRoutes);
@@ -1738,10 +1773,38 @@ if (frontendDist) {
databaseLogger.info(`Serving frontend from: ${frontendDist}`, { databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
operation: "static_files", operation: "static_files",
}); });
app.use(express.static(frontendDist)); app.use(
express.static(frontendDist, {
setHeaders: (res, filePath) => {
const relativePath = path
.relative(frontendDist, filePath)
.replaceAll(path.sep, "/");
if (relativePath.startsWith("assets/")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
if (
relativePath === "index.html" ||
relativePath === "sw.js" ||
relativePath === "manifest.json"
) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
}
},
}),
);
app.use((req, res, next) => { app.use((req, res, next) => {
if (req.method === "GET" && req.accepts("html")) { if (req.method === "GET" && req.accepts("html")) {
res.setHeader(
"Cache-Control",
"no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
);
res.sendFile(path.join(frontendDist, "index.html")); res.sendFile(path.join(frontendDist, "index.html"));
} else { } else {
next(); next();
@@ -1750,13 +1813,13 @@ if (frontendDist) {
} }
app.use( app.use(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
( (
err: unknown, err: unknown,
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
_next: express.NextFunction, _next: express.NextFunction,
) => { ) => {
void _next;
apiLogger.error("Unhandled error in request", err, { apiLogger.error("Unhandled error in request", err, {
operation: "error_handler", operation: "error_handler",
method: req.method, method: req.method,
+86 -1
View File
@@ -118,6 +118,7 @@ async function initializeDatabaseAsync(): Promise<void> {
throw new Error( throw new Error(
`Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`,
{ cause: error },
); );
} }
} else { } else {
@@ -317,6 +318,18 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ssh_folders ( CREATE TABLE IF NOT EXISTS ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -424,10 +437,31 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`); `);
try { try {
sqlite.prepare("DELETE FROM sessions").run(); const result = sqlite
.prepare("DELETE FROM sessions WHERE expires_at <= ?")
.run(new Date().toISOString());
if (result.changes > 0) {
databaseLogger.info("Expired sessions cleaned up on startup", {
operation: "db_init_session_cleanup",
deletedSessions: result.changes,
});
}
} catch (e) { } catch (e) {
databaseLogger.warn("Could not clear expired sessions on startup", { databaseLogger.warn("Could not clear expired sessions on startup", {
operation: "db_init_session_cleanup_failed", operation: "db_init_session_cleanup_failed",
@@ -803,6 +837,31 @@ const migrateSchema = () => {
} }
} }
try {
sqlite.prepare("SELECT id FROM c2s_tunnel_presets LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
config TEXT NOT NULL,
platform TEXT,
computer_name TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create c2s_tunnel_presets table", {
operation: "schema_migration",
error: createError,
});
}
}
try { try {
sqlite sqlite
.prepare("SELECT id FROM sessions LIMIT 1") .prepare("SELECT id FROM sessions LIMIT 1")
@@ -1175,6 +1234,32 @@ const migrateSchema = () => {
} }
} }
try {
sqlite.prepare("SELECT id FROM api_keys LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create api_keys table", {
operation: "schema_migration",
error: createError,
});
}
}
try { try {
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>; const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
+31
View File
@@ -298,6 +298,23 @@ export const snippetFolders = sqliteTable("snippet_folders", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
config: text("config").notNull(),
platform: text("platform"),
computerName: text("computer_name"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const snippetAccess = sqliteTable("snippet_access", { export const snippetAccess = sqliteTable("snippet_access", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
snippetId: integer("snippet_id") snippetId: integer("snippet_id")
@@ -572,3 +589,17 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
expiresAt: text("expires_at").notNull(), expiresAt: text("expires_at").notNull(),
lastUsed: text("last_used"), lastUsed: text("last_used"),
}); });
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
});
+1 -2
View File
@@ -7,7 +7,6 @@ import express from "express";
import { db } from "../db/index.js"; import { db } from "../db/index.js";
import { dismissedAlerts } from "../db/schema.js"; import { dismissedAlerts } from "../db/schema.js";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import fetch from "node-fetch";
import { authLogger } from "../../utils/logger.js"; import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { getProxyAgent } from "../../utils/proxy-agent.js"; import { getProxyAgent } from "../../utils/proxy-agent.js";
@@ -61,7 +60,7 @@ async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
Accept: "application/json", Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0", "User-Agent": "TermixAlertChecker/1.0",
}, },
agent: getProxyAgent(url), dispatcher: getProxyAgent(url),
}); });
if (!response.ok) { if (!response.ok) {
@@ -0,0 +1,247 @@
import type {
AuthenticatedRequest,
TunnelConnection,
} from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import { c2sTunnelPresets } from "../db/schema.js";
import { and, asc, eq, sql } from "drizzle-orm";
import type { Request, Response } from "express";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function parsePreset(row: typeof c2sTunnelPresets.$inferSelect) {
return {
...row,
config: JSON.parse(row.config) as TunnelConnection[],
};
}
function validateConfig(config: unknown): config is TunnelConnection[] {
if (!Array.isArray(config)) return false;
return config.every((item) => {
if (!item || typeof item !== "object") return false;
const tunnel = item as Partial<TunnelConnection>;
const mode = tunnel.mode || tunnel.tunnelType;
return (
tunnel.scope === "c2s" &&
(mode === "local" || mode === "remote" || mode === "dynamic") &&
typeof tunnel.sourcePort === "number" &&
tunnel.sourcePort >= 1 &&
tunnel.sourcePort <= 65535 &&
(mode === "dynamic" ||
(typeof tunnel.endpointPort === "number" &&
tunnel.endpointPort >= 1 &&
tunnel.endpointPort <= 65535))
);
});
}
router.get(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.userId, userId))
.orderBy(asc(c2sTunnelPresets.name));
res.json(result.map(parsePreset));
} catch (error) {
authLogger.error("Failed to fetch C2S tunnel presets", error);
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
}
},
);
router.post(
"/",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
const trimmedName = name.trim();
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (existing.length > 0) {
return res.status(409).json({ error: "Preset name already exists" });
}
const result = await db
.insert(c2sTunnelPresets)
.values({
userId,
name: trimmedName,
config: JSON.stringify(config),
platform: platform?.trim() || null,
computerName: computerName?.trim() || null,
})
.returning();
databaseLogger.info("C2S tunnel preset created", {
operation: "c2s_tunnel_preset_create",
userId,
presetId: result[0].id,
});
res.status(201).json(parsePreset(result[0]));
} catch (error) {
authLogger.error("Failed to create C2S tunnel preset", error);
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
}
},
);
router.put(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
const { name, config, platform, computerName } = req.body;
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Preset not found" });
}
const updateFields: Record<string, unknown> = {
updatedAt: sql`CURRENT_TIMESTAMP`,
};
if (name !== undefined) {
if (!isNonEmptyString(name)) {
return res.status(400).json({ error: "Preset name is required" });
}
const trimmedName = name.trim();
const duplicate = await db
.select()
.from(c2sTunnelPresets)
.where(
and(
eq(c2sTunnelPresets.userId, userId),
eq(c2sTunnelPresets.name, trimmedName),
),
);
if (duplicate.some((preset) => preset.id !== id)) {
return res.status(409).json({ error: "Preset name already exists" });
}
updateFields.name = trimmedName;
}
if (config !== undefined) {
if (!validateConfig(config)) {
return res
.status(400)
.json({ error: "Invalid C2S tunnel configuration" });
}
updateFields.config = JSON.stringify(config);
}
if (platform !== undefined)
updateFields.platform = platform?.trim() || null;
if (computerName !== undefined)
updateFields.computerName = computerName?.trim() || null;
await db
.update(c2sTunnelPresets)
.set(updateFields)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
const updated = await db
.select()
.from(c2sTunnelPresets)
.where(eq(c2sTunnelPresets.id, id));
res.json(parsePreset(updated[0]));
} catch (error) {
authLogger.error("Failed to update C2S tunnel preset", error);
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
}
},
);
router.delete(
"/:id",
authenticateJWT,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const id = Number(req.params.id);
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Preset not found" });
}
await db
.delete(c2sTunnelPresets)
.where(
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
);
res.json({ success: true });
} catch (error) {
authLogger.error("Failed to delete C2S tunnel preset", error);
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
}
},
);
export default router;
+35 -46
View File
@@ -27,6 +27,7 @@ import {
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import axios from "axios";
import multer from "multer"; import multer from "multer";
import { sshLogger, databaseLogger } from "../../utils/logger.js"; import { sshLogger, databaseLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js"; import { SimpleDBOps } from "../../utils/simple-db-ops.js";
@@ -42,6 +43,32 @@ const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() }); const upload = multer({ storage: multer.memoryStorage() });
function notifyStatsHostUpdated(
hostId: number,
headers: Pick<Request["headers"], "authorization" | "cookie">,
operation: string,
): void {
axios
.post(
"http://localhost:30005/host-updated",
{ hostId },
{
headers: {
Authorization: headers.authorization || "",
Cookie: headers.cookie || "",
},
timeout: 5000,
},
)
.catch((err) => {
sshLogger.warn("Failed to notify stats server of host update", {
operation,
hostId,
error: err instanceof Error ? err.message : String(err),
});
});
}
function isNonEmptyString(value: unknown): value is string { function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0; return typeof value === "string" && value.trim().length > 0;
} }
@@ -581,29 +608,12 @@ router.post(
name, name,
}); });
try {
const axios = (await import("axios")).default;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: createdHost.id },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of new host", {
operation: "host_create",
hostId: createdHost.id as number,
error: err instanceof Error ? err.message : String(err),
});
}
res.json(resolvedHost); res.json(resolvedHost);
notifyStatsHostUpdated(
createdHost.id as number,
req.headers,
"host_create",
);
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", err, { sshLogger.error("Failed to save SSH host to database", err, {
operation: "host_create", operation: "host_create",
@@ -1189,29 +1199,8 @@ router.put(
hostId: parseInt(hostId), hostId: parseInt(hostId),
}); });
try {
const axios = (await import("axios")).default;
const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: parseInt(hostId) },
{
headers: {
Authorization: req.headers.authorization || "",
Cookie: req.headers.cookie || "",
},
timeout: 5000,
},
);
} catch (err) {
sshLogger.warn("Failed to notify stats server of host update", {
operation: "host_update",
hostId: parseInt(hostId),
error: err instanceof Error ? err.message : String(err),
});
}
res.json(resolvedHost); res.json(resolvedHost);
notifyStatsHostUpdated(parseInt(hostId), req.headers, "host_update");
} catch (err) { } catch (err) {
sshLogger.error("Failed to update SSH host in database", err, { sshLogger.error("Failed to update SSH host in database", err, {
operation: "host_update", operation: "host_update",
@@ -3319,7 +3308,7 @@ router.patch(
.update(hosts) .update(hosts)
.set({ statsConfig: JSON.stringify(merged) }) .set({ statsConfig: JSON.stringify(merged) })
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId))); .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch (e) { } catch {
errors.push(`Failed to update statsConfig for host ${host.id}`); errors.push(`Failed to update statsConfig for host ${host.id}`);
} }
} }
@@ -5346,7 +5335,7 @@ router.post(
authenticateJWT, authenticateJWT,
requireDataAccess, requireDataAccess,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const hostId = parseInt(req.params.id); const hostId = Number.parseInt(String(req.params.id), 10);
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
+264 -48
View File
@@ -30,6 +30,7 @@ import {
networkTopology, networkTopology,
dashboardPreferences, dashboardPreferences,
opksshTokens, opksshTokens,
apiKeys,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -1142,7 +1143,11 @@ router.get("/oidc/callback", async (req, res) => {
} }
} }
if (!isFirstUser) { const oidcAllowRegistration =
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
"true";
if (!isFirstUser && !oidcAllowRegistration) {
try { try {
const regRow = db.$client const regRow = db.$client
.prepare( .prepare(
@@ -1321,10 +1326,6 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendOrigin); const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("success", "true"); redirectUrl.searchParams.set("success", "true");
if (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") {
redirectUrl.searchParams.set("token", token);
}
const maxAge = const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile" deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000 ? 30 * 24 * 60 * 60 * 1000
@@ -1576,14 +1577,6 @@ router.post("/login", async (req, res) => {
username: userRecord.username, username: userRecord.username,
}; };
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
if (isElectron) {
response.token = token;
}
const timeoutRow = db.$client const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined; .get() as { value: string } | undefined;
@@ -1621,18 +1614,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
const userId = authReq.userId; const userId = authReq.userId;
if (userId) { if (userId) {
const token = const sessionId = authReq.sessionId;
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
let sessionId: string | undefined;
if (token) {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
} catch {
// expected - token verification may fail during logout
}
}
await authManager.logoutUser(userId, sessionId); await authManager.logoutUser(userId, sessionId);
authLogger.info("User logged out", { authLogger.info("User logged out", {
@@ -1693,6 +1675,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
is_oidc: !!user[0].isOidc, is_oidc: !!user[0].isOidc,
is_dual_auth: isDualAuth, is_dual_auth: isDualAuth,
totp_enabled: !!user[0].totpEnabled, totp_enabled: !!user[0].totpEnabled,
data_unlocked: authManager.isUserUnlocked(userId),
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to get username", err); authLogger.error("Failed to get username", err);
@@ -3396,10 +3379,6 @@ router.post("/totp/verify-login", async (req, res) => {
deviceInfo: deviceInfo.deviceInfo, deviceInfo: deviceInfo.deviceInfo,
}); });
const isElectron =
req.headers["x-electron-app"] === "true" ||
req.headers["X-Electron-App"] === "true";
authLogger.success("TOTP verification successful", { authLogger.success("TOTP verification successful", {
operation: "totp_verify_success", operation: "totp_verify_success",
userId: userRecord.id, userId: userRecord.id,
@@ -3416,10 +3395,6 @@ router.post("/totp/verify-login", async (req, res) => {
totp_enabled: !!userRecord.totpEnabled, totp_enabled: !!userRecord.totpEnabled,
}; };
if (isElectron) {
response.token = token;
}
const timeoutRow = db.$client const timeoutRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
.get() as { value: string } | undefined; .get() as { value: string } | undefined;
@@ -3560,9 +3535,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
* description: Failed to unlock data. * description: Failed to unlock data.
*/ */
router.post("/unlock-data", authenticateJWT, async (req, res) => { router.post("/unlock-data", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
const { password } = req.body; const { password } = req.body;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
if (!password) { if (!password) {
return res.status(400).json({ error: "Password is required" }); return res.status(400).json({ error: "Password is required" });
} }
@@ -3570,6 +3550,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => {
try { try {
const unlocked = await authManager.authenticateUser(userId, password); const unlocked = await authManager.authenticateUser(userId, password);
if (unlocked) { if (unlocked) {
const refreshedSession =
userId && authReq.sessionId
? await authManager.refreshSessionToken(userId, authReq.sessionId)
: null;
if (refreshedSession) {
res.cookie(
"jwt",
refreshedSession.token,
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
);
}
res.json({ res.json({
success: true, success: true,
message: "Data unlocked successfully", message: "Data unlocked successfully",
@@ -3608,9 +3601,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
const unlocked = authManager.isUserUnlocked(userId);
res.json({ res.json({
unlocked: true, unlocked,
message: "Data is unlocked", message: unlocked ? "Data is unlocked" : "Data is locked",
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to check data status", err, { authLogger.error("Failed to check data status", err, {
@@ -3638,7 +3632,9 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
* description: Failed to get sessions. * description: Failed to get sessions.
*/ */
router.get("/sessions", authenticateJWT, async (req, res) => { router.get("/sessions", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId; const authReq = req as AuthenticatedRequest;
const userId = authReq.userId;
const currentSessionId = authReq.sessionId;
try { try {
const user = await db.select().from(users).where(eq(users.id, userId)); const user = await db.select().from(users).where(eq(users.id, userId));
@@ -3661,8 +3657,16 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
.limit(1); .limit(1);
return { return {
...session, id: session.id,
userId: session.userId,
username: sessionUser[0]?.username || "Unknown", username: sessionUser[0]?.username || "Unknown",
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
}; };
}), }),
); );
@@ -3670,7 +3674,19 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
return res.json({ sessions: enrichedSessions }); return res.json({ sessions: enrichedSessions });
} else { } else {
sessionList = await authManager.getUserSessions(userId); sessionList = await authManager.getUserSessions(userId);
return res.json({ sessions: sessionList }); return res.json({
sessions: sessionList.map((session) => ({
id: session.id,
userId: session.userId,
deviceType: session.deviceType,
deviceInfo: session.deviceInfo,
createdAt: session.createdAt,
expiresAt: session.expiresAt,
lastActiveAt: session.lastActiveAt,
isRevoked: session.isRevoked,
isCurrentSession: session.id === currentSessionId,
})),
});
} }
} catch (err) { } catch (err) {
authLogger.error("Failed to get sessions", err); authLogger.error("Failed to get sessions", err);
@@ -3812,12 +3828,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
let currentSessionId: string | undefined; let currentSessionId: string | undefined;
if (exceptCurrent) { if (exceptCurrent) {
const token = currentSessionId = (req as AuthenticatedRequest).sessionId;
req.cookies?.jwt || req.headers?.authorization?.split(" ")[1];
if (token) {
const payload = await authManager.verifyJWTToken(token);
currentSessionId = payload?.sessionId;
}
} }
const revokedCount = await authManager.revokeAllUserSessions( const revokedCount = await authManager.revokeAllUserSessions(
@@ -4205,7 +4216,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
* 500: * 500:
* description: Failed to get guacamole settings. * description: Failed to get guacamole settings.
*/ */
router.get("/guacamole-settings", async (req, res) => { router.get("/guacamole-settings", authenticateJWT, async (req, res) => {
try { try {
const enabledRow = db.$client const enabledRow = db.$client
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'") .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
@@ -4305,7 +4316,7 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
* 200: * 200:
* description: Current log level. * description: Current log level.
*/ */
router.get("/log-level", async (_req, res) => { router.get("/log-level", authenticateJWT, async (_req, res) => {
try { try {
const row = db.$client const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'log_level'") .prepare("SELECT value FROM settings WHERE key = 'log_level'")
@@ -4374,7 +4385,7 @@ router.patch("/log-level", authenticateJWT, async (req, res) => {
* 200: * 200:
* description: Current session timeout hours. * description: Current session timeout hours.
*/ */
router.get("/session-timeout", async (_req, res) => { router.get("/session-timeout", authenticateJWT, async (_req, res) => {
try { try {
const row = db.$client const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'") .prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
@@ -4433,4 +4444,209 @@ router.patch("/session-timeout", authenticateJWT, async (req, res) => {
} }
}); });
/**
* @openapi
* /users/api-keys:
* post:
* summary: Create an API key (admin only)
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
* tags:
* - API Keys
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - name
* - userId
* properties:
* name:
* type: string
* description: Human-readable name for the key.
* userId:
* type: string
* description: ID of the user this key is scoped to.
* expiresAt:
* type: string
* format: date-time
* description: Optional expiration date. Null means the key never expires.
* responses:
* 201:
* description: API key created. Contains the full token (shown only once).
* 400:
* description: Invalid input.
* 403:
* description: Admin access required.
* 404:
* description: Target user not found.
* 500:
* description: Failed to create API key.
*/
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ error: "name is required" });
}
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
return res.status(400).json({ error: "userId is required" });
}
const targetUser = await db
.select()
.from(users)
.where(eq(users.id, targetUserId))
.limit(1);
if (targetUser.length === 0) {
return res.status(404).json({ error: "Target user not found" });
}
let expiresAtValue: string | null = null;
if (expiresAt) {
const parsed = new Date(expiresAt);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: "Invalid expiresAt date" });
}
if (parsed <= new Date()) {
return res
.status(400)
.json({ error: "expiresAt must be in the future" });
}
expiresAtValue = parsed.toISOString();
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10);
const keyId = nanoid();
const now = new Date().toISOString();
await db.insert(apiKeys).values({
id: keyId,
userId: targetUserId,
name: name.trim(),
tokenHash,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
});
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
return res.status(201).json({
id: keyId,
name: name.trim(),
userId: targetUserId,
username: targetUser[0].username,
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
token: rawToken,
});
} catch (err) {
authLogger.error("Failed to create API key", err);
return res.status(500).json({ error: "Failed to create API key" });
}
});
/**
* @openapi
* /users/api-keys:
* get:
* summary: List all API keys (admin only)
* description: Returns all API keys with associated usernames. Token hashes are never returned.
* tags:
* - API Keys
* responses:
* 200:
* description: List of API keys.
* 403:
* description: Admin access required.
* 500:
* description: Failed to fetch API keys.
*/
router.get("/api-keys", requireAdmin, async (_req, res) => {
try {
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
userId: apiKeys.userId,
username: users.username,
tokenPrefix: apiKeys.tokenPrefix,
createdAt: apiKeys.createdAt,
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
.orderBy(apiKeys.createdAt);
return res.json({ apiKeys: keys });
} catch (err) {
authLogger.error("Failed to list API keys", err);
return res.status(500).json({ error: "Failed to fetch API keys" });
}
});
/**
* @openapi
* /users/api-keys/{keyId}:
* delete:
* summary: Delete an API key (admin only)
* description: Permanently deletes an API key. It can no longer be used to authenticate.
* tags:
* - API Keys
* parameters:
* - in: path
* name: keyId
* required: true
* schema:
* type: string
* description: The ID of the API key to delete.
* responses:
* 200:
* description: API key deleted.
* 403:
* description: Admin access required.
* 404:
* description: API key not found.
* 500:
* description: Failed to delete API key.
*/
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
try {
const keyId = String(req.params.keyId);
const existing = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.id, keyId))
.limit(1);
if (existing.length === 0) {
return res.status(404).json({ error: "API key not found" });
}
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
return res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete API key", err, {
keyId: String(req.params.keyId),
});
return res.status(500).json({ error: "Failed to delete API key" });
}
});
export default router; export default router;
@@ -1,12 +1,8 @@
import GuacamoleLite from "guacamole-lite"; import GuacamoleLite from "guacamole-lite";
import { parse as parseUrl } from "url";
import { guacLogger } from "../utils/logger.js"; import { guacLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { GuacamoleTokenService } from "./token-service.js"; import { GuacamoleTokenService } from "./token-service.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import type { IncomingMessage } from "http";
const authManager = AuthManager.getInstance();
const tokenService = GuacamoleTokenService.getInstance(); const tokenService = GuacamoleTokenService.getInstance();
function parseGuacUrl(url: string): { host: string; port: number } { function parseGuacUrl(url: string): { host: string; port: number } {
+16 -7
View File
@@ -6,7 +6,7 @@ import { PermissionManager } from "../utils/permission-manager.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { hosts } from "../database/db/schema.js"; import { hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { AuthenticatedRequest } from "../../types/index.js"; import type { AuthenticatedRequest } from "../../types/index.js";
const router = express.Router(); const router = express.Router();
@@ -31,7 +31,6 @@ router.use(authManager.createAuthMiddleware());
*/ */
router.post("/token", async (req, res) => { router.post("/token", async (req, res) => {
try { try {
const userId = (req as AuthenticatedRequest).userId;
const { type, hostname, port, username, password, domain, ...options } = const { type, hostname, port, username, password, domain, ...options } =
req.body; req.body;
@@ -63,10 +62,15 @@ router.post("/token", async (req, res) => {
); );
break; break;
case "vnc": case "vnc":
token = tokenService.createVncToken(hostname, password, { token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900, port: port || 5900,
...options, ...options,
}); },
);
break; break;
case "telnet": case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, { token = tokenService.createTelnetToken(hostname, username, password, {
@@ -129,7 +133,7 @@ router.post(
async (req: express.Request, res: express.Response) => { async (req: express.Request, res: express.Response) => {
try { try {
const userId = (req as AuthenticatedRequest).userId!; const userId = (req as AuthenticatedRequest).userId!;
const hostId = parseInt(req.params.hostId, 10); const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || isNaN(hostId)) { if (!hostId || isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" }); return res.status(400).json({ error: "Invalid host ID" });
@@ -206,10 +210,15 @@ router.post(
}); });
break; break;
case "vnc": case "vnc":
token = tokenService.createVncToken(hostname, password, { token = tokenService.createVncToken(
hostname,
username || undefined,
password,
{
port: port || 5900, port: port || 5900,
...guacConfig, ...guacConfig,
}); },
);
break; break;
case "telnet": case "telnet":
token = tokenService.createTelnetToken(hostname, username, password, { token = tokenService.createTelnetToken(hostname, username, password, {
+2
View File
@@ -141,6 +141,7 @@ export class GuacamoleTokenService {
createVncToken( createVncToken(
hostname: string, hostname: string,
username?: string,
password?: string, password?: string,
options: Partial<GuacamoleConnectionSettings["settings"]> = {}, options: Partial<GuacamoleConnectionSettings["settings"]> = {},
): string { ): string {
@@ -149,6 +150,7 @@ export class GuacamoleTokenService {
type: "vnc", type: "vnc",
settings: { settings: {
hostname, hostname,
...(username ? { username } : {}),
password, password,
port: 5900, port: 5900,
...options, ...options,
+14 -7
View File
@@ -1,6 +1,5 @@
import { Client as SSHClient } from "ssh2"; import { Client as SSHClient } from "ssh2";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { parse as parseUrl } from "url";
import { AuthManager } from "../utils/auth-manager.js"; import { AuthManager } from "../utils/auth-manager.js";
import { hosts, sshCredentials } from "../database/db/schema.js"; import { hosts, sshCredentials } from "../database/db/schema.js";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -27,15 +26,19 @@ const wss = new WebSocketServer({
port: 30009, port: 30009,
verifyClient: async (info) => { verifyClient: async (info) => {
try { try {
const url = parseUrl(info.req.url || "", true); let token: string | undefined;
let token = url.query.token as string;
if (!token) {
const cookieHeader = info.req.headers.cookie; const cookieHeader = info.req.headers.cookie;
if (cookieHeader) { if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]); if (match) token = decodeURIComponent(match[1]);
} }
if (!token) {
const authHeader = info.req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
} }
if (!token) { if (!token) {
@@ -239,15 +242,19 @@ async function createJumpHostChain(
} }
wss.on("connection", async (ws: WebSocket, req) => { wss.on("connection", async (ws: WebSocket, req) => {
const url = parseUrl(req.url || "", true); let token: string | undefined;
let token = url.query.token as string;
if (!token) {
const cookieHeader = req.headers.cookie; const cookieHeader = req.headers.cookie;
if (cookieHeader) { if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]); if (match) token = decodeURIComponent(match[1]);
} }
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
} }
if (!token) { if (!token) {
+1 -1
View File
@@ -971,7 +971,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
userId, userId,
}); });
let errorStage: ConnectionStage = "error"; let errorStage: ConnectionStage;
if ( if (
err.message.includes("ENOTFOUND") || err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo") err.message.includes("getaddrinfo")
+266 -50
View File
@@ -361,6 +361,31 @@ async function createJumpHostChain(
} }
} }
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently (one exec per command is short-lived,
// the SFTP channel is long-lived but only opened once).
class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
// Enqueue an action that opens a channel. The action runs after the previous
// one completes (success or failure). Returns a promise that resolves with
// the action's result.
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(), // run even if the previous open failed
);
// Advance tail past this slot (swallow result so the chain keeps going)
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
interface SSHSession { interface SSHSession {
client: SSHClient; client: SSHClient;
isConnected: boolean; isConnected: boolean;
@@ -369,6 +394,8 @@ interface SSHSession {
activeOperations: number; activeOperations: number;
sudoPassword?: string; sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper; sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string; poolKey?: string;
userId?: string; userId?: string;
} }
@@ -393,9 +420,11 @@ interface PendingTOTPSession {
const sshSessions: Record<string, SSHSession> = {}; const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {}; const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
const activeListRequests: Record<string, boolean> = {};
function execWithSudo( function execWithSudo(
client: SSHClient, session: SSHSession,
command: string, command: string,
sudoPassword: string, sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> { ): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -403,7 +432,7 @@ function execWithSudo(
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
client.exec(sudoCommand, (err, stream) => { execChannel(session, sudoCommand, (err, stream) => {
if (err) { if (err) {
resolve({ stdout: "", stderr: err.message, code: 1 }); resolve({ stdout: "", stderr: err.message, code: 1 });
return; return;
@@ -438,11 +467,18 @@ function getSessionSftp(
if (session.sftp) { if (session.sftp) {
return Promise.resolve(session.sftp); return Promise.resolve(session.sftp);
} }
return new Promise((resolve, reject) => {
session.client.sftp((err, sftp) => { // Serialization: if a channel open is already in flight, join it
if (err) { if (session.sftpPending) {
return reject(err); return session.sftpPending;
} }
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp; session.sftp = sftp;
sftp.on("error", () => { sftp.on("error", () => {
session.sftp = undefined; session.sftp = undefined;
@@ -452,7 +488,55 @@ function getSessionSftp(
}); });
resolve(sftp); resolve(sftp);
}); });
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
// Single retry after 500ms for transient server-side rate limiting
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
}); });
return session.sftpPending;
}
// Wraps client.exec through the channel serializer so only one SSH channel
// negotiation is in-flight at a time. The serializer slot is released as soon
// as the channel is established (not when it closes), so channels run
// concurrently once open — we only serialize the *open handshake*.
function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
} }
function cleanupSession(sessionId: string) { function cleanupSession(sessionId: string) {
@@ -840,11 +924,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
port, port,
username, username,
tryKeyboard: true, tryKeyboard: true,
keepaliveInterval: 30000, keepaliveInterval: 10000,
keepaliveCountMax: 3, keepaliveCountMax: 5,
readyTimeout: 60000, readyTimeout: 60000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 5000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier( hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId, hostId,
ip, ip,
@@ -1108,6 +1192,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
isConnected: true, isConnected: true,
lastActive: Date.now(), lastActive: Date.now(),
activeOperations: 0, activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId, userId,
}; };
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
@@ -1173,7 +1258,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
error: err.message, error: err.message,
}); });
let errorStage: ConnectionStage = "error"; let errorStage: ConnectionStage;
if ( if (
err.message.includes("ENOTFOUND") || err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo") err.message.includes("getaddrinfo")
@@ -1752,6 +1837,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
isConnected: true, isConnected: true,
lastActive: Date.now(), lastActive: Date.now(),
activeOperations: 0, activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId, userId,
}; };
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
@@ -1954,6 +2040,7 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
isConnected: true, isConnected: true,
lastActive: Date.now(), lastActive: Date.now(),
activeOperations: 0, activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId, userId,
}; };
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
@@ -2048,6 +2135,10 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body; const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const session = sshSessions[sessionId];
if (session && !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
fileLogger.info("File manager disconnection requested", { fileLogger.info("File manager disconnection requested", {
operation: "file_disconnect_request", operation: "file_disconnect_request",
sessionId, sessionId,
@@ -2149,7 +2240,7 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
* 400: * 400:
* description: Session ID is required or session not found. * description: Session ID is required or session not found.
*/ */
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { app.post("/ssh/file_manager/ssh/keepalive", async (req, res) => {
const { sessionId } = req.body; const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
@@ -2173,6 +2264,18 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
session.lastActive = Date.now(); session.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
// Probe the cached SFTP channel. If stale, clear it so the next operation
// opens a fresh one via the serialized getSessionSftp.
if (session.sftp && !session.sftpPending) {
try {
await new Promise<void>((resolve, reject) => {
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
});
} catch {
session.sftp = undefined;
}
}
res.json({ res.json({
status: "success", status: "success",
connected: true, connected: true,
@@ -2226,6 +2329,17 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
return res.status(403).json({ error: "Session access denied" }); return res.status(403).json({ error: "Session access denied" });
} }
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res.status(409).json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
sshConn.activeOperations++; sshConn.activeOperations++;
const trySFTP = () => { const trySFTP = () => {
@@ -2327,6 +2441,13 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
fileLogger.warn( fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`, `SFTP failed for listFiles, trying fallback: ${err.message}`,
); );
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod(); tryFallbackMethod();
}); });
} catch (sftpErr: unknown) { } catch (sftpErr: unknown) {
@@ -2340,11 +2461,14 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const tryFallbackMethod = () => { const tryFallbackMethod = () => {
if (!sshConn?.isConnected) { if (!sshConn?.isConnected) {
sshConn.activeOperations--; sshConn.activeOperations--;
return res.status(500).json({ error: "SSH session disconnected" }); return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
} }
try { try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec( execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`, `command ls -la --color=never '${escapedPath}'`,
(err, stream) => { (err, stream) => {
if (err) { if (err) {
@@ -2472,7 +2596,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`; const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
sshConn.client.exec(sudoCommand, (err, stream) => { execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) { if (err) {
sshConn.activeOperations--; sshConn.activeOperations--;
fileLogger.error("SSH sudo listFiles error:", err); fileLogger.error("SSH sudo listFiles error:", err);
@@ -2628,6 +2752,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const linkPath = decodeURIComponent(req.query.path as string); const linkPath = decodeURIComponent(req.query.path as string);
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" }); return res.status(400).json({ error: "Session ID is required" });
@@ -2637,6 +2762,10 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!linkPath) { if (!linkPath) {
return res.status(400).json({ error: "Link path is required" }); return res.status(400).json({ error: "Link path is required" });
} }
@@ -2646,7 +2775,7 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
const escapedPath = linkPath.replace(/'/g, "'\"'\"'"); const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`; const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
sshConn.client.exec(command, (err, stream) => { execChannel(sshConn, command, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH identifySymlink error:", err); fileLogger.error("SSH identifySymlink error:", err);
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
@@ -2722,6 +2851,7 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
const sessionId = req.query.sessionId as string; const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const rawPath = decodeURIComponent(req.query.path as string); const rawPath = decodeURIComponent(req.query.path as string);
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" }); return res.status(400).json({ error: "Session ID is required" });
@@ -2731,21 +2861,26 @@ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!rawPath) { if (!rawPath) {
return res.status(400).json({ error: "Path is required" }); return res.status(400).json({ error: "Path is required" });
} }
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
let expandPath = rawPath; let command: string;
if (expandPath.startsWith("~")) { if (rawPath.startsWith("~")) {
expandPath = "$HOME" + expandPath.substring(1); const rest = rawPath.substring(1).replace(/'/g, "'\"'\"'");
command = `echo ~'${rest}'`;
} else {
const escapedPath = rawPath.replace(/'/g, "'\"'\"'");
command = `echo '${escapedPath}'`;
} }
const escapedPath = expandPath.replace(/"/g, '\\"'); execChannel(sshConn, command, (err, stream) => {
const command = `echo "${escapedPath}"`;
sshConn.client.exec(command, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH resolvePath error:", err); fileLogger.error("SSH resolvePath error:", err);
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
@@ -2826,6 +2961,10 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) { if (!filePath) {
return res.status(400).json({ error: "File path is required" }); return res.status(400).json({ error: "File path is required" });
} }
@@ -2841,7 +2980,8 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
const MAX_READ_SIZE = 500 * 1024 * 1024; const MAX_READ_SIZE = 500 * 1024 * 1024;
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
sshConn.client.exec( execChannel(
sshConn,
`stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`, `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
(sizeErr, sizeStream) => { (sizeErr, sizeStream) => {
if (sizeErr) { if (sizeErr) {
@@ -2899,7 +3039,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
}); });
} }
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => { execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH readFile error:", err); fileLogger.error("SSH readFile error:", err);
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
@@ -3006,6 +3146,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) { if (!filePath) {
return res.status(400).json({ error: "File path is required" }); return res.status(400).json({ error: "File path is required" });
} }
@@ -3067,7 +3211,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const escapedPath = filePath.replace(/'/g, "'\"'\"'"); const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`; const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(chmodCommand, (err, stream) => { execChannel(sshConn, chmodCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.warn("Failed to restore file permissions after save", { fileLogger.warn("Failed to restore file permissions after save", {
operation: "file_write_restore_permissions", operation: "file_write_restore_permissions",
@@ -3170,6 +3314,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
} }
sftp.stat(filePath, (statErr, stats) => { sftp.stat(filePath, (statErr, stats) => {
try {
if (statErr) { if (statErr) {
fileLogger.warn( fileLogger.warn(
"Failed to read existing file permissions before save", "Failed to read existing file permissions before save",
@@ -3246,7 +3391,13 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
hasError = true; hasError = true;
isFinalizing = false; isFinalizing = false;
fileLogger.warn( fileLogger.warn(
`SFTP write operation failed, trying fallback method: ${writeErr.message}`, `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
);
tryFallbackMethod();
}
} catch (callbackErr) {
fileLogger.warn(
`SFTP stat callback error, trying fallback method: ${(callbackErr as Error).message}`,
); );
tryFallbackMethod(); tryFallbackMethod();
} }
@@ -3268,9 +3419,11 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const tryFallbackMethod = () => { const tryFallbackMethod = () => {
if (!sshConn?.isConnected) { if (!sshConn?.isConnected) {
sshConn.activeOperations--; if (!res.headersSent) {
return res.status(500).json({ error: "SSH session disconnected" }); return res.status(500).json({ error: "SSH session disconnected" });
} }
return;
}
try { try {
let contentBuffer: Buffer; let contentBuffer: Buffer;
if (typeof content === "string") { if (typeof content === "string") {
@@ -3292,7 +3445,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { execChannel(sshConn, writeCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("Fallback write command failed:", err); fileLogger.error("Fallback write command failed:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -3318,6 +3471,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
errorData += chunk.toString(); errorData += chunk.toString();
}); });
stream.stderr.on("error", (stderrErr) => {
fileLogger.error("Fallback write stderr error:", stderrErr);
});
stream.on("close", (code) => { stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) { if (outputData.includes("SUCCESS")) {
restoreOriginalMode(null, () => { restoreOriginalMode(null, () => {
@@ -3357,9 +3514,9 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
} catch (fallbackErr) { } catch (fallbackErr) {
fileLogger.error("Fallback method failed:", fallbackErr); fileLogger.error("Fallback method failed:", fallbackErr);
if (!res.headersSent) { if (!res.headersSent) {
res res.status(500).json({
.status(500) error: `All write methods failed: ${(fallbackErr as Error).message}`,
.json({ error: `All write methods failed: ${fallbackErr.message}` }); });
} }
} }
}; };
@@ -3411,6 +3568,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName || content === undefined) { if (!filePath || !fileName || content === undefined) {
return res return res
.status(400) .status(400)
@@ -3560,9 +3721,11 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
const tryFallbackMethod = () => { const tryFallbackMethod = () => {
if (!sshConn?.isConnected) { if (!sshConn?.isConnected) {
sshConn.activeOperations--; if (!res.headersSent) {
return res.status(500).json({ error: "SSH session disconnected" }); return res.status(500).json({ error: "SSH session disconnected" });
} }
return;
}
try { try {
let contentBuffer: Buffer; let contentBuffer: Buffer;
if (typeof content === "string") { if (typeof content === "string") {
@@ -3606,7 +3769,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`; const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { execChannel(sshConn, writeCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("Fallback upload command failed:", err); fileLogger.error("Fallback upload command failed:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -3680,7 +3843,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
writeCommand += ` && echo "SUCCESS"`; writeCommand += ` && echo "SUCCESS"`;
sshConn.client.exec(writeCommand, (err, stream) => { execChannel(sshConn, writeCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("Chunked fallback upload failed:", err); fileLogger.error("Chunked fallback upload failed:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -3797,6 +3960,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body; const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" }); return res.status(400).json({ error: "Session ID is required" });
@@ -3806,6 +3970,10 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) { if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" }); return res.status(400).json({ error: "File path and name are required" });
} }
@@ -3819,7 +3987,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`; const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(createCommand, (err, stream) => { execChannel(sshConn, createCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH createFile error:", err); fileLogger.error("SSH createFile error:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -3939,6 +4107,10 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) { if (!folderPath || !folderName) {
return res.status(400).json({ error: "Folder path and name are required" }); return res.status(400).json({ error: "Folder path and name are required" });
} }
@@ -3958,7 +4130,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`; const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(createCommand, (err, stream) => { execChannel(sshConn, createCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH createFolder error:", err); fileLogger.error("SSH createFolder error:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -4090,6 +4262,10 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) { if (!itemPath) {
return res.status(400).json({ error: "Item path is required" }); return res.status(400).json({ error: "Item path is required" });
} }
@@ -4111,7 +4287,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const executeDelete = (useSudo: boolean): Promise<void> => { const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) { if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then( execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => { (result) => {
if ( if (
result.code === 0 || result.code === 0 ||
@@ -4137,7 +4313,8 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
return; return;
} }
sshConn.client.exec( execChannel(
sshConn,
`${deleteCommand} && echo "SUCCESS"`, `${deleteCommand} && echo "SUCCESS"`,
(err, stream) => { (err, stream) => {
if (err) { if (err) {
@@ -4259,6 +4436,10 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) { if (!oldPath || !newName) {
return res return res
.status(400) .status(400)
@@ -4281,7 +4462,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`; const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
sshConn.client.exec(renameCommand, (err, stream) => { execChannel(sshConn, renameCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH renameItem error:", err); fileLogger.error("SSH renameItem error:", err);
if (!res.headersSent) { if (!res.headersSent) {
@@ -4412,6 +4593,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body; const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) { if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" }); return res.status(400).json({ error: "Session ID is required" });
@@ -4421,6 +4603,10 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
return res.status(400).json({ error: "SSH connection not established" }); return res.status(400).json({ error: "SSH connection not established" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) { if (!oldPath || !newPath) {
return res return res
.status(400) .status(400)
@@ -4446,7 +4632,7 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
} }
}, 60000); }, 60000);
sshConn.client.exec(moveCommand, (err, stream) => { execChannel(sshConn, moveCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem error:", err); fileLogger.error("SSH moveItem error:", err);
@@ -4566,7 +4752,8 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
* description: Failed to download file. * description: Failed to download file.
*/ */
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId, userId } = req.body; const { sessionId, path: filePath, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const downloadStartTime = Date.now(); const downloadStartTime = Date.now();
if (!sessionId || !filePath) { if (!sessionId || !filePath) {
@@ -4597,6 +4784,10 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
.json({ error: "SSH session not found or not connected" }); .json({ error: "SSH session not found or not connected" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
fileLogger.info("Opening SFTP channel", { fileLogger.info("Opening SFTP channel", {
@@ -4713,7 +4904,8 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
* description: Failed to copy item. * description: Failed to copy item.
*/ */
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !sourcePath || !targetDir) { if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" }); return res.status(400).json({ error: "Missing required parameters" });
@@ -4726,6 +4918,10 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
.json({ error: "SSH session not found or not connected" }); .json({ error: "SSH session not found or not connected" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
@@ -4757,7 +4953,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
} }
}, 60000); }, 60000);
sshConn.client.exec(copyCommand, (err, stream) => { execChannel(sshConn, copyCommand, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err); fileLogger.error("SSH copyItem error:", err);
@@ -4905,6 +5101,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body; const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) { if (!sshConn || !sshConn.isConnected) {
fileLogger.error( fileLogger.error(
@@ -4919,6 +5116,10 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
return res.status(400).json({ error: "SSH connection not available" }); return res.status(400).json({ error: "SSH connection not available" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) { if (!filePath) {
return res.status(400).json({ error: "File path is required" }); return res.status(400).json({ error: "File path is required" });
} }
@@ -4927,7 +5128,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`; const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
sshConn.client.exec(checkCommand, (checkErr, checkStream) => { execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) { if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr); fileLogger.error("SSH executeFile check error:", checkErr);
return res return res
@@ -4947,7 +5148,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`; const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
sshConn.client.exec(executeCommand, (err, stream) => { execChannel(sshConn, executeCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH executeFile error:", err); fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" }); return res.status(500).json({ error: "Failed to execute file" });
@@ -5034,6 +5235,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body; const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId]; const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) { if (!sshConn || !sshConn.isConnected) {
fileLogger.error( fileLogger.error(
@@ -5048,6 +5250,10 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
return res.status(400).json({ error: "SSH connection not available" }); return res.status(400).json({ error: "SSH connection not available" });
} }
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) { if (!path) {
return res.status(400).json({ error: "File path is required" }); return res.status(400).json({ error: "File path is required" });
} }
@@ -5086,7 +5292,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
} }
}, 10000); }, 10000);
sshConn.client.exec(command, (err, stream) => { execChannel(sshConn, command, (err, stream) => {
if (err) { if (err) {
clearTimeout(commandTimeout); clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, { fileLogger.error("SSH changePermissions exec error:", err, {
@@ -5232,6 +5438,7 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
*/ */
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body; const { sessionId, archivePath, extractPath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !archivePath) { if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" }); return res.status(400).json({ error: "Missing required parameters" });
@@ -5242,13 +5449,17 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
return res.status(400).json({ error: "SSH session not connected" }); return res.status(400).json({ error: "SSH session not connected" });
} }
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now(); session.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || ""; const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase(); const fileExt = fileName.toLowerCase();
let extractCommand = ""; let extractCommand: string;
const targetPath = const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/")); extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
@@ -5290,7 +5501,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
command: extractCommand, command: extractCommand,
}); });
session.client.exec(extractCommand, (err, stream) => { execChannel(session, extractCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH exec error during extract:", err, { fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive", operation: "extract_archive",
@@ -5438,6 +5649,7 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
*/ */
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body; const { sessionId, paths, archiveName, format } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if ( if (
!sessionId || !sessionId ||
@@ -5454,11 +5666,15 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
return res.status(400).json({ error: "SSH session not connected" }); return res.status(400).json({ error: "SSH session not connected" });
} }
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now(); session.lastActive = Date.now();
scheduleSessionCleanup(sessionId); scheduleSessionCleanup(sessionId);
const compressionFormat = format || "zip"; const compressionFormat = format || "zip";
let compressCommand = ""; let compressCommand: string;
const firstPath = paths[0]; const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/"; const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
@@ -5509,7 +5725,7 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
command: compressCommand, command: compressCommand,
}); });
session.client.exec(compressCommand, (err, stream) => { execChannel(session, compressCommand, (err, stream) => {
if (err) { if (err) {
fileLogger.error("SSH exec error during compress:", err, { fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files", operation: "compress_files",
-2
View File
@@ -1,7 +1,6 @@
import { spawn, ChildProcess } from "child_process"; import { spawn, ChildProcess } from "child_process";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { IncomingMessage } from "http";
import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js"; import { OPKSSHBinaryManager } from "../utils/opkssh-binary-manager.js";
import { sshLogger } from "../utils/logger.js"; import { sshLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
@@ -13,7 +12,6 @@ import { promises as fs } from "fs";
import path from "path"; import path from "path";
import axios from "axios"; import axios from "axios";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { getRequestOrigin } from "../utils/request-origin.js";
const AUTH_TIMEOUT = 60 * 1000; const AUTH_TIMEOUT = 60 * 1000;
+74 -12
View File
@@ -4,12 +4,64 @@
// DER → SSH wire format, and patches Protocol.authPK to use the base // DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype). // algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
import type { Client, ConnectConfig } from "ssh2"; import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
interface OPKSSHToken { interface OPKSSHToken {
privateKey: string; privateKey: string;
sshCert: string; sshCert: string;
} }
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface OPKSSHProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type OPKSSHClient = Client & {
_protocol?: OPKSSHProtocol;
};
type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
export async function setupOPKSSHCertAuth( export async function setupOPKSSHCertAuth(
config: ConnectConfig, config: ConnectConfig,
client: Client, client: Client,
@@ -26,7 +78,9 @@ export async function setupOPKSSHCertAuth(
if (parsed instanceof Error || !parsed) { if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key"); throw new Error("Failed to parse OPKSSH private key");
} }
const privKey: any = Array.isArray(parsed) ? parsed[0] : parsed; const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
// Extract cert type and blob from the stored certificate // Extract cert type and blob from the stored certificate
const certParts = token.sshCert.trim().split(/\s+/); const certParts = token.sshCert.trim().split(/\s+/);
@@ -78,36 +132,43 @@ export async function setupOPKSSHCertAuth(
// Set up authHandler to bypass ssh2's cert type rejection // Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false; let certAuthAttempted = false;
config.authHandler = ( const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[], methodsLeft: string[],
_partialSuccess: boolean, _partialSuccess: boolean,
callback: (authInfo: any) => void, callback,
) => { ) => {
const next = callback as OPKSSHNextAuthHandler;
if ( if (
!certAuthAttempted && !certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey")) (!methodsLeft || methodsLeft.includes("publickey"))
) { ) {
certAuthAttempted = true; certAuthAttempted = true;
callback({ type: "publickey", username, key: privKey }); next({
type: "publickey",
username,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else { } else {
callback(false); next(false);
} }
}; };
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature // Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types. // wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, ""); const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client); const origConnect = client.connect.bind(client);
(client as any).connect = (cfg: any) => { const patchedClient = client as OPKSSHClient;
origConnect(cfg); patchedClient.connect = (cfg: ConnectConfig) => {
const proto = (client as any)._protocol; const connectedClient = origConnect(cfg);
if (!proto) return; const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto); const origAuthPK = proto.authPK.bind(proto);
proto.authPK = ( proto.authPK = (
user: string, user: string,
pubKey: any, pubKey: ParsedPrivateKey,
keyAlgo: string | undefined, keyAlgo: string | undefined,
cbSign?: Function, cbSign?: SignCallback,
) => { ) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-"); const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) { if (!isCertAuth) {
@@ -232,5 +293,6 @@ export async function setupOPKSSHCertAuth(
proto._cipher.encrypt(finalized); proto._cipher.encrypt(finalized);
}); });
}; };
return connectedClient;
}; };
} }
+7 -7
View File
@@ -1088,8 +1088,6 @@ class PollingManager {
for (const { host, viewerUserId } of hostsToRefresh) { for (const { host, viewerUserId } of hostsToRefresh) {
await this.startPollingForHost(host, { statusOnly: true, viewerUserId }); await this.startPollingForHost(host, { statusOnly: true, viewerUserId });
} }
const skipped = this.pollingConfigs.size - hostsToRefresh.length;
} }
registerViewer(hostId: number, sessionId: string, userId: string): void { registerViewer(hostId: number, sessionId: string, userId: string): void {
@@ -1331,9 +1329,8 @@ async function resolveHostCredentials(
const isSharedHost = userId !== ownerId; const isSharedHost = userId !== ownerId;
if (isSharedHost) { if (isSharedHost) {
const { SharedCredentialManager } = await import( const { SharedCredentialManager } =
"../utils/shared-credential-manager.js" await import("../utils/shared-credential-manager.js");
);
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser( const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number, host.id as number,
@@ -1552,7 +1549,9 @@ async function buildSshConfig(
statsLogger.error( statsLogger.error(
`SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`, `SSH key format error for host ${host.ip}: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
); );
throw new Error(`Invalid SSH key format for host ${host.ip}`); throw new Error(`Invalid SSH key format for host ${host.ip}`, {
cause: keyError,
});
} }
} else if (host.authType === "none") { } else if (host.authType === "none") {
// no credentials needed // no credentials needed
@@ -1654,6 +1653,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
(proxyError instanceof Error (proxyError instanceof Error
? proxyError.message ? proxyError.message
: "Unknown error"), : "Unknown error"),
{ cause: proxyError },
); );
} }
} }
@@ -2581,7 +2581,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
let errorStage: ConnectionStage = "error"; let errorStage: ConnectionStage;
if ( if (
errorMessage.includes("ENOTFOUND") || errorMessage.includes("ENOTFOUND") ||
+150 -19
View File
@@ -3,7 +3,6 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import net from "net"; import net from "net";
import dgram from "dgram"; import dgram from "dgram";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js"; import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { parse as parseUrl } from "url";
import axios from "axios"; import axios from "axios";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sshCredentials, hosts } from "../database/db/schema.js"; import { sshCredentials, hosts } from "../database/db/schema.js";
@@ -367,15 +366,19 @@ const wss = new WebSocketServer({
port: 30002, port: 30002,
verifyClient: async (info) => { verifyClient: async (info) => {
try { try {
const url = parseUrl(info.req.url!, true); let token: string | undefined;
let token = url.query.token as string;
if (!token) {
const cookieHeader = info.req.headers.cookie; const cookieHeader = info.req.headers.cookie;
if (cookieHeader) { if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]); if (match) token = decodeURIComponent(match[1]);
} }
if (!token) {
const authHeader = info.req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
} }
if (!token) { if (!token) {
@@ -414,15 +417,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sessionId: string | undefined; let sessionId: string | undefined;
try { try {
const url = parseUrl(req.url!, true); let token: string | undefined;
let token = url.query.token as string;
if (!token) {
const cookieHeader = req.headers.cookie; const cookieHeader = req.headers.cookie;
if (cookieHeader) { if (cookieHeader) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/); const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) token = decodeURIComponent(match[1]); if (match) token = decodeURIComponent(match[1]);
} }
if (!token) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.slice("Bearer ".length);
}
} }
if (!token) { if (!token) {
@@ -487,6 +494,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isConnecting = false; let isConnecting = false;
let isConnected = false; let isConnected = false;
let isCleaningUp = false; let isCleaningUp = false;
let cwdPending = false;
let cwdBuffer = "";
let isShellInitializing = false; let isShellInitializing = false;
let warpgateAuthPromptSent = false; let warpgateAuthPromptSent = false;
let warpgateAuthTimeout: NodeJS.Timeout | null = null; let warpgateAuthTimeout: NodeJS.Timeout | null = null;
@@ -590,6 +599,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
connectData.hostConfig.userId = userId; connectData.hostConfig.userId = userId;
} }
handleConnectToHost(connectData).catch((error) => { handleConnectToHost(connectData).catch((error) => {
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
sshLogger.error("Failed to connect to host", error, { sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect", operation: "ssh_connect",
userId, userId,
@@ -599,9 +624,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
message: message: "Failed to connect to host: " + errMsg,
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
}), }),
); );
}); });
@@ -729,6 +752,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshStream = null; sshStream = null;
break; break;
case "get_cwd": {
const activeStream =
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
if (!activeStream) {
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
break;
}
cwdPending = true;
cwdBuffer = "";
// Split the sentinel across shell variables so the echoed command
// itself never contains "TERMIX_CWD:" — only the output line does.
activeStream.write('a=TERMIX_CWD; echo "$a:$(pwd)"\r');
break;
}
case "input": { case "input": {
const inputData = data as string; const inputData = data as string;
const inputStream = const inputStream =
@@ -898,6 +936,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
credentialsData.hostConfig.key = credentialsData.sshKey; credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword; credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
credentialsData.hostConfig.authType = "key"; credentialsData.hostConfig.authType = "key";
} else if (credentialsData.keyPassword) {
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
} }
isAwaitingAuthCredentials = false; isAwaitingAuthCredentials = false;
@@ -916,6 +956,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
}; };
handleConnectToHost(reconnectData).catch((error) => { handleConnectToHost(reconnectData).catch((error) => {
const errMsg =
error instanceof Error ? error.message : "Unknown error";
if (
errMsg.includes("Cannot parse privateKey") &&
errMsg.includes("no passphrase")
) {
isAwaitingAuthCredentials = true;
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
sshLogger.error("Failed to reconnect with credentials", error, { sshLogger.error("Failed to reconnect with credentials", error, {
operation: "ssh_reconnect_with_credentials", operation: "ssh_reconnect_with_credentials",
userId, userId,
@@ -925,9 +981,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "error", type: "error",
message: message: "Failed to connect with provided credentials: " + errMsg,
"Failed to connect with provided credentials: " +
(error instanceof Error ? error.message : "Unknown error"),
}), }),
); );
}); });
@@ -1196,7 +1250,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
username: resolvedHost.username || username, username: resolvedHost.username || username,
password: resolvedHost.password, password: resolvedHost.password,
key: resolvedHost.key, key: resolvedHost.key,
keyPassword: resolvedHost.keyPassword, keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType, keyType: resolvedHost.keyType,
authType: resolvedHost.authType, authType: resolvedHost.authType,
}; };
@@ -1222,7 +1276,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
username: resolvedHost.username || username, username: resolvedHost.username || username,
password: resolvedHost.password, password: resolvedHost.password,
key: resolvedHost.key, key: resolvedHost.key,
keyPassword: resolvedHost.keyPassword, // Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
keyPassword: keyPassword || resolvedHost.keyPassword,
keyType: resolvedHost.keyType, keyType: resolvedHost.keyType,
authType: resolvedHost.authType, authType: resolvedHost.authType,
}; };
@@ -1439,9 +1494,47 @@ wss.on("connection", async (ws: WebSocket, req) => {
const boundSessionId = currentSessionId; const boundSessionId = currentSessionId;
const CWD_SENTINEL = "TERMIX_CWD:";
stream.on("data", (data: Buffer) => { stream.on("data", (data: Buffer) => {
try { try {
const utf8String = data.toString("utf-8"); let utf8String = data.toString("utf-8");
if (cwdPending) {
cwdBuffer += utf8String;
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
if (sentinelIdx !== -1) {
const afterSentinel = cwdBuffer.slice(
sentinelIdx + CWD_SENTINEL.length,
);
const newlineIdx = afterSentinel.search(/[\r\n]/);
if (newlineIdx !== -1) {
const cwd =
afterSentinel.slice(0, newlineIdx).trim() || "/";
cwdPending = false;
// Strip the sentinel line from output sent to terminal
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
const afterNewline = afterSentinel.slice(newlineIdx);
utf8String = beforeSentinel + afterNewline;
cwdBuffer = "";
const attachedWs =
sessionManager.getSession(boundSessionId)?.attachedWs ??
ws;
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({ type: "cwd", path: cwd }),
);
}
} else {
return;
}
} else {
return;
}
}
if (!utf8String) return;
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session) { if (session) {
sessionManager.bufferOutput(boundSessionId!, utf8String); sessionManager.bufferOutput(boundSessionId!, utf8String);
@@ -1472,9 +1565,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
}); });
stream.on("close", () => { stream.on("close", (code: number | null) => {
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) { if (session?.attachedWs?.readyState === WebSocket.OPEN) {
if (code != null) {
session.attachedWs.send(
JSON.stringify({
type: "session_ended",
code,
}),
);
} else {
session.attachedWs.send( session.attachedWs.send(
JSON.stringify({ JSON.stringify({
type: "disconnected", type: "disconnected",
@@ -1482,6 +1583,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}), }),
); );
} }
}
if (boundSessionId) { if (boundSessionId) {
sessionManager.destroySession(boundSessionId); sessionManager.destroySession(boundSessionId);
if (currentSessionId === boundSessionId) { if (currentSessionId === boundSessionId) {
@@ -1740,6 +1842,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; return;
} }
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
sendLog(
"auth",
"error",
"SSH key is encrypted but no passphrase was provided",
);
isAwaitingAuthCredentials = true;
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
}
cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "passphrase_required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
}),
);
return;
}
if ( if (
authMethodNotAvailable && authMethodNotAvailable &&
resolvedCredentials.authType === "none" && resolvedCredentials.authType === "none" &&
@@ -1913,7 +2040,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
}), }),
); );
} }
} else if (!sshStream) { } else {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
@@ -2102,6 +2229,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (resolvedCredentials.keyPassword) { if (resolvedCredentials.keyPassword) {
connectConfig.passphrase = resolvedCredentials.keyPassword; connectConfig.passphrase = resolvedCredentials.keyPassword;
} }
if (resolvedCredentials.password) {
connectConfig.password = resolvedCredentials.password;
}
} catch (keyError) { } catch (keyError) {
sshLogger.error("SSH key format error: " + keyError.message); sshLogger.error("SSH key format error: " + keyError.message);
ws.send( ws.send(
@@ -2191,7 +2322,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
{ operation: "port_knock", hostId: hostConfig.id }, { operation: "port_knock", hostId: hostConfig.id },
); );
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence); await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
} catch (err) { } catch {
sshLogger.warn("Port knocking failed, attempting connection anyway", { sshLogger.warn("Port knocking failed, attempting connection anyway", {
operation: "port_knock", operation: "port_knock",
hostId: hostConfig.id, hostId: hostConfig.id,
+1220 -236
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -79,7 +79,6 @@ export async function collectCpuMetrics(client: Client): Promise<{
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch { } catch {
cpuPercent = null; cpuPercent = null;
cores = null;
loadTriplet = null; loadTriplet = null;
} }
+1
View File
@@ -98,6 +98,7 @@ import {
const systemCrypto = SystemCrypto.getInstance(); const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret(); await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey(); await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken(); await systemCrypto.initializeInternalAuthToken();
await AutoSSLSetup.initialize(); await AutoSSLSetup.initialize();
+8 -3
View File
@@ -1,15 +1,20 @@
import swaggerJSDoc from "swagger-jsdoc"; import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { systemLogger } from "./utils/logger.js"; import { systemLogger } from "./utils/logger.js";
interface SwaggerOptions {
definition: object;
apis: string[];
}
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const projectRoot = path.join(__dirname, "..", "..", ".."); const projectRoot = path.join(__dirname, "..", "..", "..");
const swaggerOptions: swaggerJSDoc.Options = { const swaggerOptions: SwaggerOptions = {
definition: { definition: {
openapi: "3.0.3", openapi: "3.0.3",
info: { info: {
@@ -130,7 +135,7 @@ async function generateOpenAPISpec() {
operation: "openapi_generate_start", operation: "openapi_generate_start",
}); });
const swaggerSpec = swaggerJSDoc(swaggerOptions); const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(projectRoot, "openapi.json"); const outputPath = path.join(projectRoot, "openapi.json");
+293 -16
View File
@@ -1,15 +1,13 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import crypto from "crypto";
import { UserCrypto } from "./user-crypto.js"; import { UserCrypto } from "./user-crypto.js";
import { SystemCrypto } from "./system-crypto.js"; import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import { databaseLogger, authLogger } from "./logger.js"; import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { import bcrypt from "bcryptjs";
db, import { db } from "../database/db/index.js";
getSqlite, import { sessions, trustedDevices, apiKeys } from "../database/db/schema.js";
saveMemoryDatabaseToFile,
} from "../database/db/index.js";
import { sessions, trustedDevices } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js"; import type { DeviceType } from "./user-agent-parser.js";
@@ -29,12 +27,21 @@ interface JWTPayload {
userId: string; userId: string;
sessionId?: string; sessionId?: string;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKeyWrap?: WrappedDataKey;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }
interface WrappedDataKey {
version: "v1";
iv: string;
tag: string;
data: string;
}
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
sessionId?: string;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
} }
@@ -198,6 +205,113 @@ class AuthManager {
} }
} }
private getDataKeyAAD(userId: string, sessionId?: string): Buffer {
return Buffer.from(`${userId}:${sessionId || ""}`, "utf8");
}
private async wrapUserDataKey(
userId: string,
sessionId: string | undefined,
dataKey: Buffer,
): Promise<WrappedDataKey> {
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
cipher.setAAD(this.getDataKeyAAD(userId, sessionId));
const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]);
const tag = cipher.getAuthTag();
return {
version: "v1",
iv: iv.toString("base64url"),
tag: tag.toString("base64url"),
data: encrypted.toString("base64url"),
};
}
private async unwrapUserDataKey(
userId: string,
sessionId: string | undefined,
wrapped: WrappedDataKey,
): Promise<Buffer> {
if (wrapped.version !== "v1") {
throw new Error(
`Unsupported wrapped data key version: ${wrapped.version}`,
);
}
const encryptionKey = await this.systemCrypto.getEncryptionKey();
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(wrapped.iv, "base64url"),
);
decipher.setAAD(this.getDataKeyAAD(userId, sessionId));
decipher.setAuthTag(Buffer.from(wrapped.tag, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(wrapped.data, "base64url")),
decipher.final(),
]);
}
private async addWrappedDataKey(payload: JWTPayload): Promise<void> {
if (payload.pendingTOTP) {
return;
}
const dataKey = this.userCrypto.getUserDataKey(payload.userId);
if (!dataKey) {
return;
}
payload.dataKeyWrap = await this.wrapUserDataKey(
payload.userId,
payload.sessionId,
dataKey,
);
}
private async restoreDataKeyFromPayload(
payload: JWTPayload,
sessionExpiresAt?: string,
): Promise<void> {
if (
!payload.dataKeyWrap ||
this.userCrypto.getUserDataKey(payload.userId)
) {
return;
}
const expiresAt = sessionExpiresAt
? new Date(sessionExpiresAt).getTime()
: payload.exp
? payload.exp * 1000
: Date.now();
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
return;
}
try {
const dataKey = await this.unwrapUserDataKey(
payload.userId,
payload.sessionId,
payload.dataKeyWrap,
);
this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt);
dataKey.fill(0);
} catch (error) {
databaseLogger.warn("Failed to restore data key from session token", {
operation: "session_data_key_restore_failed",
userId: payload.userId,
sessionId: payload.sessionId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
async generateJWTToken( async generateJWTToken(
userId: string, userId: string,
options: { options: {
@@ -234,6 +348,7 @@ class AuthManager {
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
const sessionId = nanoid(); const sessionId = nanoid();
payload.sessionId = sessionId; payload.sessionId = sessionId;
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, jwtSecret, { const token = jwt.sign(payload, jwtSecret, {
expiresIn, expiresIn,
@@ -281,6 +396,7 @@ class AuthManager {
return token; return token;
} }
await this.addWrappedDataKey(payload);
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
} }
@@ -327,6 +443,11 @@ class AuthManager {
}); });
return null; return null;
} }
await this.restoreDataKeyFromPayload(
payload,
sessionRecords[0].expiresAt,
);
} catch (dbError) { } catch (dbError) {
databaseLogger.error( databaseLogger.error(
"Failed to check session in database during JWT verification", "Failed to check session in database during JWT verification",
@@ -338,6 +459,8 @@ class AuthManager {
); );
return null; return null;
} }
} else {
await this.restoreDataKeyFromPayload(payload);
} }
return payload; return payload;
} catch (error) { } catch (error) {
@@ -350,12 +473,63 @@ class AuthManager {
} }
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars async refreshSessionToken(
userId: string,
sessionId: string,
): Promise<{ token: string; maxAge: number } | null> {
const sessionRecords = await db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.limit(1);
if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) {
return null;
}
const expiresAt = new Date(sessionRecords[0].expiresAt).getTime();
const maxAge = expiresAt - Date.now();
if (!Number.isFinite(maxAge) || maxAge <= 0) {
return null;
}
const payload: JWTPayload = { userId, sessionId };
await this.addWrappedDataKey(payload);
const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
expiresIn: Math.ceil(maxAge / 1000),
} as jwt.SignOptions);
await db
.update(sessions)
.set({
jwtToken: token,
lastActiveAt: new Date().toISOString(),
})
.where(eq(sessions.id, sessionId));
try {
const { saveMemoryDatabaseToFile } =
await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
"Failed to save database after session token refresh",
saveError,
{
operation: "session_token_refresh_db_save_failed",
sessionId,
},
);
}
return { token, maxAge };
}
invalidateJWTToken(_token: string): void { invalidateJWTToken(_token: string): void {
// expected - no-op, JWT tokens are stateless // expected - no-op, JWT tokens are stateless
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
invalidateUserTokens(_userId: string): void { invalidateUserTokens(_userId: string): void {
// expected - no-op, handled by session management // expected - no-op, handled by session management
} }
@@ -537,9 +711,9 @@ class AuthManager {
maxAge: number = 24 * 60 * 60 * 1000, maxAge: number = 24 * 60 * 60 * 1000,
) { ) {
return { return {
httpOnly: false, httpOnly: true,
secure: req.secure || req.headers["x-forwarded-proto"] === "https", secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const, sameSite: "lax" as const,
maxAge: maxAge, maxAge: maxAge,
path: "/", path: "/",
}; };
@@ -547,13 +721,88 @@ class AuthManager {
getClearCookieOptions(req: RequestWithHeaders) { getClearCookieOptions(req: RequestWithHeaders) {
return { return {
httpOnly: false, httpOnly: true,
secure: req.secure || req.headers["x-forwarded-proto"] === "https", secure: req.secure || req.headers["x-forwarded-proto"] === "https",
sameSite: "strict" as const, sameSite: "lax" as const,
path: "/", path: "/",
}; };
} }
private async handleApiKeyAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
token: string,
requireAdmin = false,
): Promise<void> {
try {
const tokenPrefix = token.substring(0, 12);
const candidates = await db
.select()
.from(apiKeys)
.where(
and(eq(apiKeys.tokenPrefix, tokenPrefix), eq(apiKeys.isActive, true)),
);
if (candidates.length === 0) {
res.status(401).json({ error: "Invalid API key" });
return;
}
let matchedKey: (typeof candidates)[0] | null = null;
for (const candidate of candidates) {
if (await bcrypt.compare(token, candidate.tokenHash)) {
matchedKey = candidate;
break;
}
}
if (!matchedKey) {
res.status(401).json({ error: "Invalid API key" });
return;
}
if (matchedKey.expiresAt && new Date(matchedKey.expiresAt) < new Date()) {
res.status(401).json({ error: "API key has expired" });
return;
}
if (requireAdmin) {
const { users } = await import("../database/db/schema.js");
const userRows = await db
.select()
.from(users)
.where(eq(users.id, matchedKey.userId))
.limit(1);
if (!userRows[0]?.isAdmin) {
res.status(403).json({ error: "Admin access required" });
return;
}
}
db.update(apiKeys)
.set({ lastUsedAt: new Date().toISOString() })
.where(eq(apiKeys.id, matchedKey.id))
.then(() => {})
.catch((err) => {
databaseLogger.warn("Failed to update API key lastUsedAt", {
operation: "api_key_update_last_used",
keyId: matchedKey!.id,
error: err instanceof Error ? err.message : "Unknown",
});
});
req.userId = matchedKey.userId;
next();
} catch (error) {
databaseLogger.error("API key authentication failed", error, {
operation: "api_key_auth_failed",
});
res.status(500).json({ error: "API key authentication failed" });
}
}
createAuthMiddleware() { createAuthMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest; const authReq = req as AuthenticatedRequest;
@@ -570,10 +819,17 @@ class AuthManager {
return res.status(401).json({ error: "Missing authentication token" }); return res.status(401).json({ error: "Missing authentication token" });
} }
if (token.startsWith("tmx_")) {
return this.handleApiKeyAuth(authReq, res, next, token);
}
const payload = await this.verifyJWTToken(token); const payload = await this.verifyJWTToken(token);
if (!payload) { if (!payload) {
return res.status(401).json({ error: "Invalid token" }); return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
} }
if (payload.pendingTOTP) { if (payload.pendingTOTP) {
@@ -597,7 +853,10 @@ class AuthManager {
sessionId: payload.sessionId, sessionId: payload.sessionId,
userId: payload.userId, userId: payload.userId,
}); });
return res.status(401).json({ return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session not found", error: "Session not found",
code: "SESSION_NOT_FOUND", code: "SESSION_NOT_FOUND",
}); });
@@ -657,7 +916,10 @@ class AuthManager {
); );
}); });
return res.status(401).json({ return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({
error: "Session has expired", error: "Session has expired",
code: "SESSION_EXPIRED", code: "SESSION_EXPIRED",
}); });
@@ -684,6 +946,7 @@ class AuthManager {
} }
authReq.userId = payload.userId; authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP; authReq.pendingTOTP = payload.pendingTOTP;
next(); next();
}; };
@@ -718,10 +981,23 @@ class AuthManager {
return res.status(401).json({ error: "Missing authentication token" }); return res.status(401).json({ error: "Missing authentication token" });
} }
if (token.startsWith("tmx_")) {
return this.handleApiKeyAuth(
req as AuthenticatedRequest,
res,
next,
token,
true,
);
}
const payload = await this.verifyJWTToken(token); const payload = await this.verifyJWTToken(token);
if (!payload) { if (!payload) {
return res.status(401).json({ error: "Invalid token" }); return res
.clearCookie("jwt", this.getClearCookieOptions(req))
.status(401)
.json({ error: "Invalid token" });
} }
if (payload.pendingTOTP) { if (payload.pendingTOTP) {
@@ -755,6 +1031,7 @@ class AuthManager {
const authReq = req as AuthenticatedRequest; const authReq = req as AuthenticatedRequest;
authReq.userId = payload.userId; authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP; authReq.pendingTOTP = payload.pendingTOTP;
next(); next();
} catch (error) { } catch (error) {
+1
View File
@@ -171,6 +171,7 @@ IP.3 = 0.0.0.0
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`, `SSL certificate generation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
); );
} }
} }
-1
View File
@@ -53,7 +53,6 @@ export function createCorsMiddleware(
return callback(null, true); return callback(null, true);
const configured = getAllowedOrigins(); const configured = getAllowedOrigins();
if (configured.length === 0) return callback(null, true);
if (configured.includes("*") || configured.includes(origin)) if (configured.includes("*") || configured.includes(origin))
return callback(null, true); return callback(null, true);
@@ -102,6 +102,7 @@ class DatabaseFileEncryption {
}); });
throw new Error( throw new Error(
`Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, `Database buffer encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
); );
} }
} }
@@ -197,6 +198,7 @@ class DatabaseFileEncryption {
}); });
throw new Error( throw new Error(
`Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, `Database file encryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
); );
} }
} }
@@ -237,6 +239,7 @@ class DatabaseFileEncryption {
if (!fs.existsSync(metadataPath)) { if (!fs.existsSync(metadataPath)) {
throw new Error( throw new Error(
`Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`, `Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`,
{ cause: singleFileError },
); );
} }
@@ -247,6 +250,7 @@ class DatabaseFileEncryption {
} catch (twoFileError) { } catch (twoFileError) {
throw new Error( throw new Error(
`Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`, `Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`,
{ cause: twoFileError },
); );
} }
} }
@@ -358,6 +362,7 @@ class DatabaseFileEncryption {
`- .env file readable: ${envFileReadable}\n` + `- .env file readable: ${envFileReadable}\n` +
`- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` +
`Original error: ${errorMessage}`, `Original error: ${errorMessage}`,
{ cause: error },
); );
} }
@@ -366,7 +371,9 @@ class DatabaseFileEncryption {
encryptedPath, encryptedPath,
errorMessage, errorMessage,
}); });
throw new Error(`Database buffer decryption failed: ${errorMessage}`); throw new Error(`Database buffer decryption failed: ${errorMessage}`, {
cause: error,
});
} }
} }
@@ -398,6 +405,7 @@ class DatabaseFileEncryption {
}); });
throw new Error( throw new Error(
`Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, `Database file decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
); );
} }
} }
+3 -2
View File
@@ -50,8 +50,8 @@ export class DatabaseMigration {
} }
} }
let needsMigration = false; let needsMigration: boolean;
let reason = ""; let reason: string;
if (hasEncryptedDb && hasUnencryptedDb) { if (hasEncryptedDb && hasUnencryptedDb) {
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size; const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
@@ -119,6 +119,7 @@ export class DatabaseMigration {
}); });
throw new Error( throw new Error(
`Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`, `Backup creation failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
); );
} }
} }
+13 -2
View File
@@ -1,4 +1,5 @@
import chalk from "chalk"; import chalk from "chalk";
import type { ChalkInstance } from "chalk";
export type LogLevel = "debug" | "info" | "warn" | "error" | "success"; export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
@@ -78,7 +79,17 @@ export class Logger {
} }
private getTimeStamp(): string { private getTimeStamp(): string {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`); const now = new Date();
const format = process.env.LOG_TIMESTAMP_FORMAT?.toLowerCase();
let time: string;
if (format === "iso") {
time = now.toISOString();
} else if (format === "24h") {
time = now.toLocaleTimeString("en-GB", { hour12: false });
} else {
time = now.toLocaleTimeString();
}
return chalk.gray(`[${time}]`);
} }
private sanitizeContext(context: LogContext): LogContext { private sanitizeContext(context: LogContext): LogContext {
@@ -149,7 +160,7 @@ export class Logger {
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`; return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
} }
private getLevelColor(level: LogLevel): chalk.Chalk { private getLevelColor(level: LogLevel): ChalkInstance {
switch (level) { switch (level) {
case "debug": case "debug":
return chalk.magenta; return chalk.magenta;
+4 -4
View File
@@ -1,7 +1,7 @@
import { HttpsProxyAgent } from "https-proxy-agent"; import { ProxyAgent } from "undici";
import type { Agent } from "http"; import type { Dispatcher } from "undici-types";
export function getProxyAgent(targetUrl?: string): Agent | undefined { export function getProxyAgent(targetUrl?: string): Dispatcher | undefined {
const proxyUrl = const proxyUrl =
process.env.https_proxy || process.env.https_proxy ||
process.env.HTTPS_PROXY || process.env.HTTPS_PROXY ||
@@ -26,5 +26,5 @@ export function getProxyAgent(targetUrl?: string): Agent | undefined {
} }
} }
return new HttpsProxyAgent(proxyUrl); return new ProxyAgent(proxyUrl) as unknown as Dispatcher;
} }
+66 -4
View File
@@ -7,6 +7,7 @@ class SystemCrypto {
private static instance: SystemCrypto; private static instance: SystemCrypto;
private jwtSecret: string | null = null; private jwtSecret: string | null = null;
private databaseKey: Buffer | null = null; private databaseKey: Buffer | null = null;
private encryptionKey: Buffer | null = null;
private internalAuthToken: string | null = null; private internalAuthToken: string | null = null;
private credentialSharingKey: Buffer | null = null; private credentialSharingKey: Buffer | null = null;
@@ -61,7 +62,7 @@ class SystemCrypto {
databaseLogger.error("Failed to initialize JWT secret", error, { databaseLogger.error("Failed to initialize JWT secret", error, {
operation: "jwt_init_failed", operation: "jwt_init_failed",
}); });
throw new Error("JWT secret initialization failed"); throw new Error("JWT secret initialization failed", { cause: error });
} }
} }
@@ -103,7 +104,7 @@ class SystemCrypto {
operation: "db_key_init_failed", operation: "db_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data", dataDir: process.env.DATA_DIR || "./db/data",
}); });
throw new Error("Database key initialization failed"); throw new Error("Database key initialization failed", { cause: error });
} }
} }
@@ -114,6 +115,46 @@ class SystemCrypto {
return this.databaseKey!; return this.databaseKey!;
} }
async initializeEncryptionKey(): Promise<void> {
try {
const dataDir = process.env.DATA_DIR || "./db/data";
const envPath = path.join(dataDir, ".env");
const envKey = process.env.ENCRYPTION_KEY;
if (envKey && envKey.length >= 64) {
this.encryptionKey = Buffer.from(envKey, "hex");
return;
}
try {
const envContent = await fs.readFile(envPath, "utf8");
const keyMatch = envContent.match(/^ENCRYPTION_KEY=(.+)$/m);
if (keyMatch && keyMatch[1] && keyMatch[1].length >= 64) {
this.encryptionKey = Buffer.from(keyMatch[1], "hex");
process.env.ENCRYPTION_KEY = keyMatch[1];
return;
}
} catch {
// expected - env file may not exist
}
await this.generateAndGuideEncryptionKey();
} catch (error) {
databaseLogger.error("Failed to initialize encryption key", error, {
operation: "encryption_key_init_failed",
dataDir: process.env.DATA_DIR || "./db/data",
});
throw new Error("Encryption key initialization failed", { cause: error });
}
}
async getEncryptionKey(): Promise<Buffer> {
if (!this.encryptionKey) {
await this.initializeEncryptionKey();
}
return this.encryptionKey!;
}
async initializeInternalAuthToken(): Promise<void> { async initializeInternalAuthToken(): Promise<void> {
try { try {
const envToken = process.env.INTERNAL_AUTH_TOKEN; const envToken = process.env.INTERNAL_AUTH_TOKEN;
@@ -142,7 +183,9 @@ class SystemCrypto {
databaseLogger.error("Failed to initialize internal auth token", error, { databaseLogger.error("Failed to initialize internal auth token", error, {
operation: "internal_auth_init_failed", operation: "internal_auth_init_failed",
}); });
throw new Error("Internal auth token initialization failed"); throw new Error("Internal auth token initialization failed", {
cause: error,
});
} }
} }
@@ -186,7 +229,9 @@ class SystemCrypto {
dataDir: process.env.DATA_DIR || "./db/data", dataDir: process.env.DATA_DIR || "./db/data",
}, },
); );
throw new Error("Credential sharing key initialization failed"); throw new Error("Credential sharing key initialization failed", {
cause: error,
});
} }
} }
@@ -230,6 +275,23 @@ class SystemCrypto {
}); });
} }
private async generateAndGuideEncryptionKey(): Promise<void> {
const newKey = crypto.randomBytes(32);
const newKeyHex = newKey.toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex");
this.encryptionKey = newKey;
await this.updateEnvFile("ENCRYPTION_KEY", newKeyHex);
databaseLogger.success("Encryption key auto-generated and saved to .env", {
operation: "encryption_key_auto_generated",
instanceId,
envVarName: "ENCRYPTION_KEY",
note: "Used to wrap session data keys - no restart required",
});
}
private async generateAndGuideInternalAuthToken(): Promise<void> { private async generateAndGuideInternalAuthToken(): Promise<void> {
const newToken = crypto.randomBytes(32).toString("hex"); const newToken = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex");
+6 -10
View File
@@ -255,16 +255,12 @@ function parseMacVersion(userAgent: string): string {
* Ignores minor version numbers to handle browser auto-updates. * Ignores minor version numbers to handle browser auto-updates.
*/ */
export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string { export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string {
let fingerprintString = ""; const fingerprintString =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
if (deviceInfo.type === "desktop") { ? `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; : `${deviceInfo.type}|${deviceInfo.browser} ${
} else if (deviceInfo.type === "mobile") { deviceInfo.version.split(".")[0]
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`; }|${deviceInfo.os}`;
} else {
const browserMajor = deviceInfo.version.split(".")[0];
fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`;
}
return crypto.createHash("sha256").update(fingerprintString).digest("hex"); return crypto.createHash("sha256").update(fingerprintString).digest("hex");
} }
+13
View File
@@ -265,6 +265,19 @@ class UserCrypto {
return session.dataKey; return session.dataKey;
} }
restoreUserDataKey(userId: string, dataKey: Buffer, expiresAt: number): void {
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
this.userSessions.set(userId, {
dataKey: Buffer.from(dataKey),
expiresAt,
lastActivity: Date.now(),
});
}
logoutUser(userId: string): void { logoutUser(userId: string): void {
const session = this.userSessions.get(userId); const session = this.userSessions.get(userId);
if (session) { if (session) {
+1 -1
View File
@@ -482,7 +482,7 @@ class UserDataImport {
return await this.importUserData(targetUserId, exportData, options); return await this.importUserData(targetUserId, exportData, options);
} catch (error) { } catch (error) {
if (error instanceof SyntaxError) { if (error instanceof SyntaxError) {
throw new Error("Invalid JSON format in import data"); throw new Error("Invalid JSON format in import data", { cause: error });
} }
throw error; throw error;
} }
+677
View File
@@ -0,0 +1,677 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const getAllItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
+50 -19
View File
@@ -1,28 +1,59 @@
import { type ComponentProps, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { export type KbdProps = ComponentProps<"span"> & {
return ( children: ReactNode;
<kbd };
data-slot="kbd"
export const Kbd = ({ className, children, ...props }: KbdProps) => (
<span
className={cn( className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none", "inline-flex select-none items-center rounded-md border px-2 py-1 text-[10px] font-mono font-medium relative",
"[&_svg:not([class*='size-'])]:size-3", "bg-linear-to-b from-gray-100 to-gray-200 border-gray-300 shadow-[0_2px_0_#ccc,0_3px_2px_rgba(0,0,0,0.25)]",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10", "dark:from-zinc-800 dark:to-zinc-900 dark:border-zinc-700 dark:shadow-[0_2px_0_#222,0_3px_2px_rgba(0,0,0,0.4)]",
"dark:text-zinc-200",
className, className,
)} )}
{...props} {...props}
/> >
); {children}
} </span>
);
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { export type KbdKeyProps = ComponentProps<"span"> & {
return ( "aria-label"?: string;
<kbd className?: string;
data-slot="kbd-group" };
className={cn("inline-flex items-center gap-1", className)}
export const KbdKey = ({ className, children, ...props }: KbdKeyProps) => (
<span
className={cn(
"px-1 py-px rounded-sm select-none text-[10px] font-mono font-medium bg-transparent",
className,
)}
{...props} {...props}
/> >
); {children}
} </span>
);
export { Kbd, KbdGroup }; export type KbdSeparatorProps = ComponentProps<"span"> & {
children?: ReactNode;
className?: string;
};
export const KbdSeparator = ({
className,
children = "+",
...props
}: KbdSeparatorProps) => (
<span
className={cn(
"text-muted-foreground/70 text-[10px] mx-0.5 select-none pointer-events-none",
className,
)}
{...props}
>
{children}
</span>
);
+12 -8
View File
@@ -1,15 +1,19 @@
import * as React from "react"; import * as React from "react";
import { GripVerticalIcon } from "lucide-react"; import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels"; import {
Group as ResizableGroup,
Panel as ResizablePrimitivePanel,
Separator as ResizableSeparator,
} from "react-resizable-panels";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function ResizablePanelGroup({ function ResizablePanelGroup({
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) { }: React.ComponentProps<typeof ResizableGroup>) {
return ( return (
<ResizablePrimitive.PanelGroup <ResizableGroup
data-slot="resizable-panel-group" data-slot="resizable-panel-group"
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
@@ -22,19 +26,19 @@ function ResizablePanelGroup({
function ResizablePanel({ function ResizablePanel({
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) { }: React.ComponentProps<typeof ResizablePrimitivePanel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />; return <ResizablePrimitivePanel data-slot="resizable-panel" {...props} />;
} }
function ResizableHandle({ function ResizableHandle({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizableSeparator> & {
withHandle?: boolean; withHandle?: boolean;
}) { }) {
return ( return (
<ResizablePrimitive.PanelResizeHandle <ResizableSeparator
data-slot="resizable-handle" data-slot="resizable-handle"
className={cn( className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-edge-hover hover:bg-interact active:bg-pressed transition-colors duration-150", "relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-edge-hover hover:bg-interact active:bg-pressed transition-colors duration-150",
@@ -47,7 +51,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" /> <GripVerticalIcon className="size-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizableSeparator>
); );
} }
+2 -2
View File
@@ -598,9 +598,9 @@ function SidebarMenuSkeleton({
showIcon?: boolean; showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`; return `${Math.floor(Math.random() * 40) + 50}%`;
}, []); });
return ( return (
<div <div
+17 -2
View File
@@ -1,13 +1,13 @@
import React from "react"; import React from "react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { ExternalLink, Download, AlertTriangle } from "lucide-react"; import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface VersionAlertProps { interface VersionAlertProps {
updateInfo: { updateInfo: {
success: boolean; success: boolean;
status?: "up_to_date" | "requires_update"; status?: "up_to_date" | "requires_update" | "beta";
localVersion?: string; localVersion?: string;
remoteVersion?: string; remoteVersion?: string;
latest_release?: { latest_release?: {
@@ -53,6 +53,21 @@ export function VersionAlert({ updateInfo, onDownload }: VersionAlertProps) {
); );
} }
if (updateInfo.status === "beta") {
return (
<Alert>
<Info className="h-4 w-4" />
<AlertTitle>{t("versionCheck.betaVersion")}</AlertTitle>
<AlertDescription>
{t("versionCheck.betaVersionDesc", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "requires_update") { if (updateInfo.status === "requires_update") {
return ( return (
<Alert variant="destructive"> <Alert variant="destructive">
+1 -1
View File
@@ -144,7 +144,7 @@ export function useConfirmation() {
setPendingConfirmCallback(null); setPendingConfirmCallback(null);
setPendingResolve(null); setPendingResolve(null);
}, },
} as any); } as NonNullable<Parameters<typeof toast>[1]>);
if (confirmOnEnter) { if (confirmOnEnter) {
setActiveToastId(toastId); setActiveToastId(toastId);
+4 -4
View File
@@ -3,8 +3,9 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768; const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( const [isMobile, setIsMobile] = React.useState<boolean>(
undefined, () =>
typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
); );
React.useEffect(() => { React.useEffect(() => {
@@ -13,9 +14,8 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
}; };
mql.addEventListener("change", onChange); mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange); return () => mql.removeEventListener("change", onChange);
}, []); }, []);
return !!isMobile; return isMobile;
} }
+29 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { isElectron } from "@/ui/main-axios"; import { isElectron } from "@/lib/electron";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
interface ServiceWorkerState { interface ServiceWorkerState {
@@ -40,27 +40,54 @@ export function useServiceWorker(): ServiceWorkerState {
if (!isSupported) return; if (!isSupported) return;
const shouldReloadOnControllerChange = Boolean(
navigator.serviceWorker.controller,
);
let hasReloadedForUpdate = false;
const handleControllerChange = () => {
if (!shouldReloadOnControllerChange || hasReloadedForUpdate) {
return;
}
hasReloadedForUpdate = true;
window.location.reload();
};
const registerSW = async () => { const registerSW = async () => {
try { try {
const registration = await navigator.serviceWorker.register( const registration = await navigator.serviceWorker.register(
`${getBasePath()}/sw.js`, `${getBasePath()}/sw.js`,
{ updateViaCache: "none" },
); );
setState((prev) => ({ ...prev, isRegistered: true })); setState((prev) => ({ ...prev, isRegistered: true }));
registration.addEventListener("updatefound", () => registration.addEventListener("updatefound", () =>
handleUpdateFound(registration), handleUpdateFound(registration),
); );
await registration.update();
} catch (error) { } catch (error) {
console.error("[SW] Registration failed:", error); console.error("[SW] Registration failed:", error);
} }
}; };
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange,
);
if (document.readyState === "complete") { if (document.readyState === "complete") {
registerSW(); registerSW();
} else { } else {
window.addEventListener("load", registerSW); window.addEventListener("load", registerSW);
return () => window.removeEventListener("load", registerSW);
} }
return () => {
window.removeEventListener("load", registerSW);
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange,
);
};
}, [handleUpdateFound]); }, [handleUpdateFound]);
return state; return state;
+71 -174
View File
@@ -1,84 +1,82 @@
import i18n from "i18next"; import i18n, { type BackendModule, type ResourceKey } from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector"; import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en.json"; import enTranslation from "../locales/en.json";
import afTranslation from "../locales/translated/af_ZA.json";
import arTranslation from "../locales/translated/ar_SA.json"; type LocaleModule = { default: ResourceKey };
import bnTranslation from "../locales/translated/bn_BD.json";
import bgTranslation from "../locales/translated/bg_BG.json"; const localeLoaders = {
import caTranslation from "../locales/translated/ca_ES.json"; af: () => import("../locales/translated/af_ZA.json"),
import csTranslation from "../locales/translated/cs_CZ.json"; ar: () => import("../locales/translated/ar_SA.json"),
import daTranslation from "../locales/translated/da_DK.json"; bn: () => import("../locales/translated/bn_BD.json"),
import deTranslation from "../locales/translated/de_DE.json"; bg: () => import("../locales/translated/bg_BG.json"),
import elTranslation from "../locales/translated/el_GR.json"; ca: () => import("../locales/translated/ca_ES.json"),
import esESTranslation from "../locales/translated/es_ES.json"; cs: () => import("../locales/translated/cs_CZ.json"),
import fiTranslation from "../locales/translated/fi_FI.json"; da: () => import("../locales/translated/da_DK.json"),
import frTranslation from "../locales/translated/fr_FR.json"; de: () => import("../locales/translated/de_DE.json"),
import heTranslation from "../locales/translated/he_IL.json"; el: () => import("../locales/translated/el_GR.json"),
import hiTranslation from "../locales/translated/hi_IN.json"; "es-ES": () => import("../locales/translated/es_ES.json"),
import huTranslation from "../locales/translated/hu_HU.json"; fi: () => import("../locales/translated/fi_FI.json"),
import idTranslation from "../locales/translated/id_ID.json"; fr: () => import("../locales/translated/fr_FR.json"),
import itTranslation from "../locales/translated/it_IT.json"; he: () => import("../locales/translated/he_IL.json"),
import jaTranslation from "../locales/translated/ja_JP.json"; hi: () => import("../locales/translated/hi_IN.json"),
import koTranslation from "../locales/translated/ko_KR.json"; hu: () => import("../locales/translated/hu_HU.json"),
import nlTranslation from "../locales/translated/nl_NL.json"; id: () => import("../locales/translated/id_ID.json"),
import noTranslation from "../locales/translated/no_NO.json"; it: () => import("../locales/translated/it_IT.json"),
import plTranslation from "../locales/translated/pl_PL.json"; ja: () => import("../locales/translated/ja_JP.json"),
import ptPTTranslation from "../locales/translated/pt_PT.json"; ko: () => import("../locales/translated/ko_KR.json"),
import ptBRTranslation from "../locales/translated/pt_BR.json"; nl: () => import("../locales/translated/nl_NL.json"),
import roTranslation from "../locales/translated/ro_RO.json"; no: () => import("../locales/translated/no_NO.json"),
import ruTranslation from "../locales/translated/ru_RU.json"; pl: () => import("../locales/translated/pl_PL.json"),
import srTranslation from "../locales/translated/sr_SP.json"; "pt-PT": () => import("../locales/translated/pt_PT.json"),
import svSETranslation from "../locales/translated/sv_SE.json"; "pt-BR": () => import("../locales/translated/pt_BR.json"),
import thTranslation from "../locales/translated/th_TH.json"; ro: () => import("../locales/translated/ro_RO.json"),
import trTranslation from "../locales/translated/tr_TR.json"; ru: () => import("../locales/translated/ru_RU.json"),
import ukTranslation from "../locales/translated/uk_UA.json"; sr: () => import("../locales/translated/sr_SP.json"),
import viTranslation from "../locales/translated/vi_VN.json"; "sv-SE": () => import("../locales/translated/sv_SE.json"),
import zhCNTranslation from "../locales/translated/zh_CN.json"; th: () => import("../locales/translated/th_TH.json"),
import zhTWTranslation from "../locales/translated/zh_TW.json"; tr: () => import("../locales/translated/tr_TR.json"),
uk: () => import("../locales/translated/uk_UA.json"),
vi: () => import("../locales/translated/vi_VN.json"),
"zh-CN": () => import("../locales/translated/zh_CN.json"),
"zh-TW": () => import("../locales/translated/zh_TW.json"),
} satisfies Record<string, () => Promise<LocaleModule>>;
const supportedLngs = ["en", ...Object.keys(localeLoaders)];
const localeBackend: BackendModule = {
type: "backend",
init: () => {},
read: (language, _namespace, callback) => {
if (language === "en") {
callback(null, enTranslation);
return;
}
const loadLocale = localeLoaders[language];
if (!loadLocale) {
callback(new Error(`Unsupported language: ${language}`), false);
return;
}
loadLocale()
.then((module) => callback(null, module.default))
.catch((error: unknown) => {
callback(
error instanceof Error ? error : new Error(String(error)),
false,
);
});
},
};
i18n i18n
.use(localeBackend)
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
supportedLngs: [ supportedLngs,
"en",
"af",
"ar",
"bn",
"bg",
"ca",
"cs",
"da",
"de",
"el",
"es-ES",
"fi",
"fr",
"he",
"hi",
"hu",
"id",
"it",
"ja",
"ko",
"nl",
"no",
"pl",
"pt-PT",
"pt-BR",
"ro",
"ru",
"sr",
"sv-SE",
"th",
"tr",
"uk",
"vi",
"zh-CN",
"zh-TW",
],
fallbackLng: "en", fallbackLng: "en",
debug: false, debug: false,
@@ -94,109 +92,8 @@ i18n
en: { en: {
translation: enTranslation, translation: enTranslation,
}, },
af: {
translation: afTranslation,
},
ar: {
translation: arTranslation,
},
bn: {
translation: bnTranslation,
},
bg: {
translation: bgTranslation,
},
ca: {
translation: caTranslation,
},
cs: {
translation: csTranslation,
},
da: {
translation: daTranslation,
},
de: {
translation: deTranslation,
},
el: {
translation: elTranslation,
},
"es-ES": {
translation: esESTranslation,
},
fi: {
translation: fiTranslation,
},
fr: {
translation: frTranslation,
},
he: {
translation: heTranslation,
},
hi: {
translation: hiTranslation,
},
hu: {
translation: huTranslation,
},
id: {
translation: idTranslation,
},
it: {
translation: itTranslation,
},
ja: {
translation: jaTranslation,
},
ko: {
translation: koTranslation,
},
nl: {
translation: nlTranslation,
},
no: {
translation: noTranslation,
},
pl: {
translation: plTranslation,
},
"pt-PT": {
translation: ptPTTranslation,
},
"pt-BR": {
translation: ptBRTranslation,
},
ro: {
translation: roTranslation,
},
ru: {
translation: ruTranslation,
},
sr: {
translation: srTranslation,
},
"sv-SE": {
translation: svSETranslation,
},
th: {
translation: thTranslation,
},
tr: {
translation: trTranslation,
},
uk: {
translation: ukTranslation,
},
vi: {
translation: viTranslation,
},
"zh-CN": {
translation: zhCNTranslation,
},
"zh-TW": {
translation: zhTWTranslation,
},
}, },
partialBundledLanguages: true,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
+4
View File
@@ -553,3 +553,7 @@
.skinny-scrollbar::-webkit-scrollbar-thumb:hover { .skinny-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover); background: var(--scrollbar-thumb-hover);
} }
.h-fade {
mask-image: linear-gradient(transparent, #000 6%, #000 94%, transparent);
}
+45
View File
@@ -0,0 +1,45 @@
const CLIENT_CACHE_VERSION_KEY = "termix_client_cache_version";
const CURRENT_CLIENT_VERSION = import.meta.env.VITE_APP_VERSION || "0.0.0";
async function clearCacheStorage(): Promise<void> {
if (!("caches" in window)) return;
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
async function clearServiceWorkers(): Promise<void> {
if (!("serviceWorker" in navigator)) return;
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map((registration) => registration.unregister()),
);
}
function storeCurrentVersion(): void {
try {
localStorage.setItem(CLIENT_CACHE_VERSION_KEY, CURRENT_CLIENT_VERSION);
} catch {
// expected - storage can be unavailable in restricted contexts
}
}
export async function prepareClientCacheVersion(): Promise<void> {
if (typeof window === "undefined") return;
let storedVersion: string | null = null;
try {
storedVersion = localStorage.getItem(CLIENT_CACHE_VERSION_KEY);
} catch {
storedVersion = null;
}
if (storedVersion === CURRENT_CLIENT_VERSION) {
return;
}
await Promise.allSettled([clearCacheStorage(), clearServiceWorkers()]);
storeCurrentVersion();
}
+13 -5
View File
@@ -12,6 +12,12 @@ export class RobustClipboardProvider implements IClipboardProvider {
if (this.pendingWrite !== null) { if (this.pendingWrite !== null) {
const text = this.pendingWrite; const text = this.pendingWrite;
this.pendingWrite = null; this.pendingWrite = null;
if (window.electronClipboard) {
window.electronClipboard.writeText(text).catch(() => {
this.pendingWrite = text;
});
return;
}
navigator.clipboard.writeText(text).catch(() => { navigator.clipboard.writeText(text).catch(() => {
this.pendingWrite = text; this.pendingWrite = text;
}); });
@@ -25,18 +31,20 @@ export class RobustClipboardProvider implements IClipboardProvider {
this.pendingWrite = null; this.pendingWrite = null;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars readText(_selection: ClipboardSelectionType): string | Promise<string> {
readText(selection: ClipboardSelectionType): string { if (window.electronClipboard) {
return ""; return window.electronClipboard.readText();
}
return navigator.clipboard?.readText?.() ?? "";
} }
async writeText( async writeText(
selection: ClipboardSelectionType, _selection: ClipboardSelectionType,
text: string, text: string,
): Promise<void> { ): Promise<void> {
try { try {
if (window.electronClipboard) { if (window.electronClipboard) {
window.electronClipboard.writeText(text); await window.electronClipboard.writeText(text);
return; return;
} }
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
+19 -6
View File
@@ -14,7 +14,18 @@
* to reflect the current UX contract: users can keep working regardless * to reflect the current UX contract: users can keep working regardless
* of backend hiccups and are simply informed via a toast. * of backend hiccups and are simply informed via a toast.
*/ */
type EventListener = (...args: any[]) => void; type EventListener = (...args: unknown[]) => void;
interface HttpLikeError {
message?: string;
code?: string;
response?: {
data?: {
error?: string;
code?: string;
};
};
}
class DatabaseHealthMonitor { class DatabaseHealthMonitor {
private static instance: DatabaseHealthMonitor; private static instance: DatabaseHealthMonitor;
@@ -47,7 +58,7 @@ class DatabaseHealthMonitor {
} }
} }
private emit(event: string, ...args: any[]): void { private emit(event: string, ...args: unknown[]): void {
const eventListeners = this.listeners.get(event); const eventListeners = this.listeners.get(event);
if (eventListeners) { if (eventListeners) {
eventListeners.forEach((listener) => listener(...args)); eventListeners.forEach((listener) => listener(...args));
@@ -58,9 +69,11 @@ class DatabaseHealthMonitor {
this.emit("session-expired", { timestamp: Date.now() }); this.emit("session-expired", { timestamp: Date.now() });
} }
reportDatabaseError(error: any, _wasAuthenticated: boolean = false) { reportDatabaseError(error: unknown) {
const errorMessage = error?.response?.data?.error || error?.message || ""; const errorLike = error as HttpLikeError;
const errorCode = error?.response?.data?.code || error?.code; const errorMessage =
errorLike.response?.data?.error || errorLike.message || "";
const errorCode = errorLike.response?.data?.code || errorLike.code;
const lowerMessage = errorMessage.toLowerCase(); const lowerMessage = errorMessage.toLowerCase();
const isDatabaseError = const isDatabaseError =
@@ -78,7 +91,7 @@ class DatabaseHealthMonitor {
errorCode === "ETIMEDOUT" || errorCode === "ETIMEDOUT" ||
errorCode === "ERR_CANCELED" || errorCode === "ERR_CANCELED" ||
(lowerMessage.includes("network error") && (lowerMessage.includes("network error") &&
error?.response === undefined) || errorLike.response === undefined) ||
lowerMessage.includes("request aborted") || lowerMessage.includes("request aborted") ||
lowerMessage.includes("timeout"); lowerMessage.includes("timeout");
+18
View File
@@ -0,0 +1,18 @@
type ElectronWindow = Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: {
isElectron?: boolean;
};
};
export function isElectron(): boolean {
if (typeof window === "undefined") return false;
const win = window as ElectronWindow;
const hasISElectron = win.IS_ELECTRON === true;
const hasElectronAPI = !!win.electronAPI;
const isElectronProp = win.electronAPI?.isElectron === true;
return hasISElectron || hasElectronAPI || isElectronProp;
}
+147 -29
View File
@@ -402,6 +402,8 @@
"currentVersion": "You are running version {{version}}", "currentVersion": "You are running version {{version}}",
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.", "newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.",
"betaVersion": "Beta Version",
"betaVersionDesc": "You are running {{current}}, which is newer than the latest stable release {{latest}}.",
"releasedOn": "Released on {{date}}", "releasedOn": "Released on {{date}}",
"downloadUpdate": "Download Update", "downloadUpdate": "Download Update",
"dismiss": "Dismiss", "dismiss": "Dismiss",
@@ -426,6 +428,8 @@
"warning": "Warning", "warning": "Warning",
"info": "Info", "info": "Info",
"success": "Success", "success": "Success",
"unsavedChanges": "Unsaved changes",
"dismiss": "Dismiss",
"loading": "Loading...", "loading": "Loading...",
"required": "Required", "required": "Required",
"optional": "Optional", "optional": "Optional",
@@ -471,6 +475,7 @@
"chinese": "Chinese", "chinese": "Chinese",
"german": "German", "german": "German",
"cancel": "Cancel", "cancel": "Cancel",
"done": "Done",
"username": "Username", "username": "Username",
"name": "Name", "name": "Name",
"login": "Login", "login": "Login",
@@ -486,6 +491,7 @@
"save": "Save", "save": "Save",
"saving": "Saving...", "saving": "Saving...",
"delete": "Delete", "delete": "Delete",
"rename": "Rename",
"edit": "Edit", "edit": "Edit",
"add": "Add", "add": "Add",
"search": "Search", "search": "Search",
@@ -559,7 +565,8 @@
"passwordCopied": "Password copied to clipboard", "passwordCopied": "Password copied to clipboard",
"sudoPasswordCopied": "Sudo password copied to clipboard", "sudoPasswordCopied": "Sudo password copied to clipboard",
"noPasswordAvailable": "No password available", "noPasswordAvailable": "No password available",
"failedToCopyPassword": "Failed to copy password" "failedToCopyPassword": "Failed to copy password",
"openFileManager": "Open File Manager"
}, },
"admin": { "admin": {
"title": "Admin Settings", "title": "Admin Settings",
@@ -876,7 +883,42 @@
"passwordMinLength": "Password must be at least 6 characters", "passwordMinLength": "Password must be at least 6 characters",
"currentRoles": "Current Roles", "currentRoles": "Current Roles",
"noRolesAssigned": "No roles assigned", "noRolesAssigned": "No roles assigned",
"assignNewRole": "Assign New Role" "assignNewRole": "Assign New Role",
"apiKeys": {
"tabLabel": "API Keys",
"title": "API Keys",
"createApiKey": "Create API Key",
"createApiKeyDescription": "Create a new API key scoped to a specific user. The token is shown only once.",
"keyCreated": "API Key Created",
"keyCreatedDescription": "Copy this key now — it will not be shown again.",
"keyName": "Key Name",
"keyNamePlaceholder": "e.g. CI/CD Pipeline",
"scopedUser": "Scoped User",
"selectUser": "Select a user...",
"searchUsers": "Search users...",
"noUsersFound": "No users found.",
"expiresAt": "Expires At",
"optional": "optional",
"expiresAtHelp": "Leave empty for a key that never expires.",
"copyWarningTitle": "Save your API key",
"copyWarningDescription": "This key will only be shown once. Store it in a safe place.",
"apiKey": "API Key",
"tokenCopied": "Token copied to clipboard",
"creating": "Creating...",
"nameRequired": "Key name is required",
"userRequired": "Please select a user",
"failedToCreate": "Failed to create API key",
"noKeys": "No API keys found.",
"name": "Name",
"prefix": "Prefix",
"lastUsed": "Last Used",
"never": "Never",
"revokeKey": "Revoke key",
"confirmRevoke": "Are you sure you want to revoke the API key \"{{name}}\"? This cannot be undone.",
"revokedSuccessfully": "API key revoked successfully",
"failedToRevoke": "Failed to revoke API key",
"failedToFetch": "Failed to fetch API keys"
}
}, },
"hosts": { "hosts": {
"title": "Host Manager", "title": "Host Manager",
@@ -970,28 +1012,8 @@
"enableDocker": "Enable Docker", "enableDocker": "Enable Docker",
"defaultPath": "Default Path", "defaultPath": "Default Path",
"defaultPathDesc": "Default directory when opening file manager for this host", "defaultPathDesc": "Default directory when opening file manager for this host",
"tunnelConnections": "Tunnel Connections",
"connection": "Connection", "connection": "Connection",
"remove": "Remove", "remove": "Remove",
"sourcePort": "Source Port",
"sourcePortDesc": " (Source refers to the Current Connection Details in the General tab)",
"endpointPort": "Endpoint Port",
"endpointSshConfig": "Endpoint SSH Configuration",
"tunnelForwardDescription": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
"maxRetries": "Max Retries",
"maxRetriesDescription": "Maximum number of retry attempts for tunnel connection.",
"retryInterval": "Retry Interval (seconds)",
"retryIntervalDescription": "Time to wait between retry attempts.",
"autoStartContainer": "Auto Start on Container Launch",
"autoStartDesc": "Automatically start this tunnel when the container launches",
"addConnection": "Add Tunnel Connection",
"tunnelType": "Tunnel Type",
"tunnelTypeLocal": "Local (-L)",
"tunnelTypeRemote": "Remote (-R)",
"tunnelTypeLocalDesc": "Forward local port to remote endpoint",
"tunnelTypeRemoteDesc": "Forward remote port to local machine",
"tunnelForwardDescriptionLocal": "This tunnel will forward traffic from local port {{sourcePort}} to port {{endpointPort}} on the endpoint machine.",
"tunnelForwardDescriptionRemote": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.",
"sshpassRequired": "Sshpass Required For Password Authentication", "sshpassRequired": "Sshpass Required For Password Authentication",
"sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.", "sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.",
"otherInstallMethods": "Other installation methods:", "otherInstallMethods": "Other installation methods:",
@@ -1656,6 +1678,7 @@
"automaticFallback": "Automatically trying {{method}} authentication...", "automaticFallback": "Automatically trying {{method}} authentication...",
"totpTimeout": "TOTP verification timeout. Please reconnect.", "totpTimeout": "TOTP verification timeout. Please reconnect.",
"passwordTimeout": "Password verification timeout. Please reconnect.", "passwordTimeout": "Password verification timeout. Please reconnect.",
"sessionEnded": "Session ended.",
"connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.", "connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.",
"hostKeyRejected": "SSH host key verification rejected. Connection cancelled.", "hostKeyRejected": "SSH host key verification rejected. Connection cancelled.",
"sessionTakenOver": "Session was opened in another tab. Reconnecting...", "sessionTakenOver": "Session was opened in another tab. Reconnecting...",
@@ -1993,12 +2016,10 @@
"ascending": "Ascending", "ascending": "Ascending",
"descending": "Descending" "descending": "Descending"
}, },
"tunnel": {
"noTunnelsConfigured": "No Tunnels Configured",
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started"
},
"tunnels": { "tunnels": {
"title": "SSH Tunnels", "title": "SSH Tunnels",
"noTunnelsConfigured": "No Tunnels Configured",
"configureTunnelsInHostSettings": "Configure tunnel connections in the Host Manager to get started",
"noSshTunnels": "No SSH Tunnels", "noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.", "createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
"connected": "Connected", "connected": "Connected",
@@ -2019,27 +2040,100 @@
"disconnect": "Disconnect", "disconnect": "Disconnect",
"cancel": "Cancel", "cancel": "Cancel",
"port": "Port", "port": "Port",
"localPort": "Local Port",
"remotePort": "Remote Port",
"currentHostPort": "Current Host Port",
"endpointPort": "Endpoint Port",
"bindIp": "Local IP",
"currentHostIp": "Current Host IP",
"endpointSshConfig": "Endpoint SSH Configuration",
"endpointSshConfigRequired": "Endpoint SSH configuration is required",
"endpointSshHost": "Endpoint SSH Host",
"endpointSshHostPlaceholder": "Select a configured host",
"endpointSshHostRequired": "Select an endpoint SSH host for each client tunnel.",
"attempt": "Attempt {{current}} of {{max}}", "attempt": "Attempt {{current}} of {{max}}",
"nextRetryIn": "Next retry in {{seconds}} seconds", "nextRetryIn": "Next retry in {{seconds}} seconds",
"checkDockerLogs": "Check your Docker logs for the error reason, join the", "checkDockerLogs": "Check your Docker logs for the error reason, join the",
"orCreate": "or create a ", "orCreate": "or create a ",
"noTunnelConnections": "No tunnel connections configured", "noTunnelConnections": "No tunnel connections configured",
"tunnelConnections": "Tunnel Connections", "tunnelConnections": "Tunnel Connections",
"serverTunnels": "Server Tunnels",
"serverTunnelsDesc": "Backend-managed tunnels stored with this host.",
"clientTunnels": "Client Tunnels",
"clientTunnelsUnavailable": "Client tunnels require a desktop client.",
"serverTunnel": "Server Tunnel",
"clientTunnel": "Client Tunnel",
"addServerTunnel": "Add Server Tunnel",
"addClientTunnel": "Add Client Tunnel",
"noServerTunnels": "No server tunnels configured.",
"noClientTunnels": "No client tunnels configured on this desktop.",
"manageClientTunnels": "Manage Client Tunnels",
"addTunnel": "Add Tunnel", "addTunnel": "Add Tunnel",
"editTunnel": "Edit Tunnel", "editTunnel": "Edit Tunnel",
"deleteTunnel": "Delete Tunnel", "deleteTunnel": "Delete Tunnel",
"tunnelName": "Tunnel Name", "tunnelName": "Tunnel Name",
"localPort": "Local Port",
"remoteHost": "Remote Host", "remoteHost": "Remote Host",
"remotePort": "Remote Port",
"autoStart": "Auto Start", "autoStart": "Auto Start",
"autoStartContainer": "Auto Start on Launch",
"autoStartContainerDesc": "Automatically start this tunnel when your Termix server launches.",
"autoStartEnableFailed": "Host saved, but failed to start auto-start tunnels for {{name}}.",
"clientAutoStartDesc": "Starts when this desktop client opens and stays connected.",
"clientManualStartDesc": "Use Start and Stop from this row. Termix will not open it automatically.",
"clientRemoteServerNote": "Remote forwarding may require AllowTcpForwarding and GatewayPorts on the endpoint SSH server. The remote port closes when this desktop disconnects.",
"clientTunnelStarted": "Client tunnel started",
"clientTunnelStopped": "Client tunnel stopped",
"tunnelTestSucceeded": "Tunnel test succeeded",
"tunnelTestFailed": "Tunnel test failed",
"localSaved": "Client tunnels saved",
"localSaveError": "Failed to save local client tunnels",
"invalidBindIp": "Local IP must be a valid IPv4 address.",
"invalidLocalTargetIp": "Local target IP must be a valid IPv4 address.",
"invalidCurrentHostIp": "Current Host IP must be a valid IPv4 address.",
"invalidLocalPort": "Local port must be between 1 and 65535.",
"invalidRemotePort": "Remote port must be between 1 and 65535.",
"invalidLocalTargetPort": "Local target port must be between 1 and 65535.",
"invalidEndpointPort": "Endpoint port must be between 1 and 65535.",
"duplicateAutoStartBind": "Only one auto-start client tunnel can use {{bind}}.",
"manualControlError": "Failed to update tunnel state.",
"saveHostBeforeManualControl": "Save this host before starting or stopping its server tunnels.",
"status": "Status", "status": "Status",
"active": "Active", "active": "Active",
"inactive": "Inactive", "inactive": "Inactive",
"start": "Start", "start": "Start",
"stop": "Stop", "stop": "Stop",
"test": "Test",
"restart": "Restart", "restart": "Restart",
"connectionType": "Connection Type", "connectionType": "Connection Type",
"type": "Tunnel Type",
"typeLocal": "Local (-L)",
"typeRemote": "Remote (-R)",
"typeDynamic": "Dynamic (-D)",
"typeServerLocalDesc": "Current host to endpoint.",
"typeServerRemoteDesc": "Endpoint back to current host.",
"typeClientLocalDesc": "Local computer to endpoint.",
"typeClientRemoteDesc": "Endpoint back to local computer.",
"typeClientDynamicDesc": "SOCKS on local computer.",
"typeDynamicDesc": "Forward SOCKS5 CONNECT traffic through SSH",
"forwardDescriptionServerLocal": "Current host {{sourcePort}} → endpoint {{endpointPort}}.",
"forwardDescriptionServerRemote": "Endpoint {{endpointPort}} → current host {{sourcePort}}.",
"forwardDescriptionServerDynamic": "SOCKS on current host {{sourcePort}}.",
"forwardDescriptionClientLocal": "Local {{sourcePort}} → remote {{endpointPort}}.",
"forwardDescriptionClientRemote": "Remote {{sourcePort}} → local {{endpointPort}}.",
"forwardDescriptionClientDynamic": "SOCKS on local port {{sourcePort}}.",
"summaryClientLocal": "{{localHost}}:{{localPort}} → {{endpoint}}:{{remotePort}}",
"summaryClientRemote": "{{endpoint}}:{{remotePort}} → {{localHost}}:{{localPort}}",
"summaryClientDynamic": "{{localHost}}:{{localPort}} → SOCKS via {{endpoint}}",
"autoNameClientLocal": "Local {{localPort}} → {{endpoint}} {{remotePort}}",
"autoNameClientRemote": "{{endpoint}} {{remotePort}} → local {{localPort}}",
"autoNameClientDynamic": "SOCKS {{localPort}} via {{endpoint}}",
"route": "Route:",
"lastStarted": "Last started",
"lastTested": "Last tested",
"lastError": "Last error",
"maxRetries": "Max Retries",
"maxRetriesDescription": "Maximum amount of retry attempts.",
"retryInterval": "Retry Interval (seconds)",
"retryIntervalDescription": "Time to wait between retry attempts.",
"local": "Local", "local": "Local",
"remote": "Remote", "remote": "Remote",
"dynamic": "Dynamic", "dynamic": "Dynamic",
@@ -2223,6 +2317,8 @@
"sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.", "sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.",
"sshPasswordDescription": "Enter the password for this SSH connection.", "sshPasswordDescription": "Enter the password for this SSH connection.",
"sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.", "sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.",
"passphraseRequired": "Passphrase Required",
"passphraseRequiredDescription": "The SSH key is encrypted. Please enter the passphrase to unlock it.",
"step1ScanQR": "Step 1: Scan the QR code with your authenticator app", "step1ScanQR": "Step 1: Scan the QR code with your authenticator app",
"manualEntryCode": "Manual Entry Code", "manualEntryCode": "Manual Entry Code",
"cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app", "cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app",
@@ -2297,7 +2393,7 @@
"failedCompleteReset": "Failed to complete password reset", "failedCompleteReset": "Failed to complete password reset",
"invalidTotpCode": "Invalid TOTP code", "invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Failed to start OIDC login", "failedOidcLogin": "Failed to start OIDC login",
"failedUserInfo": "Failed to get user info after OIDC login", "failedUserInfo": "Failed to get user info after login",
"oidcAuthFailed": "OIDC authentication failed", "oidcAuthFailed": "OIDC authentication failed",
"noTokenReceived": "No token received from login", "noTokenReceived": "No token received from login",
"invalidAuthUrl": "Invalid authorization URL received from backend", "invalidAuthUrl": "Invalid authorization URL received from backend",
@@ -2373,6 +2469,23 @@
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.", "showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
"account": "Account", "account": "Account",
"appearance": "Appearance", "appearance": "Appearance",
"c2sTunnelConfigDesc": "Local desktop tunnels targeting configured SSH hosts.",
"c2sTunnelPresets": "Client Tunnel Presets",
"c2sTunnelPresetsDesc": "Save this desktop client's local tunnel list as a named server preset, or load a preset back into this client.",
"c2sTunnelPresetsUnavailable": "Client tunnel presets are only available in the desktop client.",
"c2sPresetName": "Preset Name",
"c2sPresetNamePlaceholder": "Client preset name",
"c2sPresetToLoad": "Preset To Load",
"c2sNoPresetSelected": "No preset selected",
"c2sNoPresets": "No presets saved",
"c2sLoadPreset": "Load",
"c2sCurrentLocalConfig": "{{count}} local client tunnel(s) configured on this desktop.",
"c2sPresetSyncNote": "Presets are explicit snapshots; loading one replaces this desktop client's local client tunnel list.",
"c2sPresetSaved": "Client tunnel preset saved",
"c2sPresetLoaded": "Client tunnel preset loaded locally",
"c2sPresetRenamed": "Client tunnel preset renamed",
"c2sPresetDeleted": "Client tunnel preset deleted",
"c2sPresetLoadError": "Failed to load client tunnel presets",
"languageLocalization": "Language & Localization", "languageLocalization": "Language & Localization",
"fileManagerSettings": "File Manager", "fileManagerSettings": "File Manager",
"terminalSettings": "Terminal", "terminalSettings": "Terminal",
@@ -2421,6 +2534,10 @@
"description": "SSH credential description", "description": "SSH credential description",
"searchCredentials": "Search credentials by name, username, or tags...", "searchCredentials": "Search credentials by name, username, or tags...",
"sshConfig": "endpoint ssh configuration", "sshConfig": "endpoint ssh configuration",
"bindLocalhost": "127.0.0.1 (bind to localhost)",
"localListenerHost": "127.0.0.1 (listen locally)",
"localTargetHost": "127.0.0.1 (target on this computer)",
"socksListenerHost": "127.0.0.1 (SOCKS listener)",
"homePath": "/home", "homePath": "/home",
"clientId": "your-client-id", "clientId": "your-client-id",
"clientSecret": "your-client-secret", "clientSecret": "your-client-secret",
@@ -2600,6 +2717,7 @@
"version": "Version", "version": "Version",
"upToDate": "Up to Date", "upToDate": "Up to Date",
"updateAvailable": "Update Available", "updateAvailable": "Update Available",
"beta": "Beta",
"uptime": "Uptime", "uptime": "Uptime",
"database": "Database", "database": "Database",
"healthy": "Healthy", "healthy": "Healthy",
+1 -1
View File
@@ -2246,7 +2246,7 @@
"failedCompleteReset": "Échec de la réinitialisation du mot de passe", "failedCompleteReset": "Échec de la réinitialisation du mot de passe",
"invalidTotpCode": "Invalid TOTP code", "invalidTotpCode": "Invalid TOTP code",
"failedOidcLogin": "Impossible de démarrer la connexion OIDC", "failedOidcLogin": "Impossible de démarrer la connexion OIDC",
"failedUserInfo": "Impossible d'obtenir les informations de l'utilisateur après la connexion OIDC", "failedUserInfo": "Impossible d'obtenir les informations de l'utilisateur après la connexion",
"oidcAuthFailed": "Échec de l'authentification OIDC", "oidcAuthFailed": "Échec de l'authentification OIDC",
"noTokenReceived": "Aucun jeton reçu de la connexion", "noTokenReceived": "Aucun jeton reçu de la connexion",
"invalidAuthUrl": "URL d'autorisation invalide reçue du backend", "invalidAuthUrl": "URL d'autorisation invalide reçue du backend",
+47 -16
View File
@@ -1,20 +1,44 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { StrictMode, useEffect, useState, useRef } from "react"; import { prepareClientCacheVersion } from "@/lib/client-cache-version";
import { StrictMode, Suspense, lazy, useEffect, useState, useRef } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import DesktopApp from "@/ui/desktop/DesktopApp.tsx";
import { MobileApp } from "@/ui/mobile/MobileApp.tsx";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n"; import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts"; import { isElectron } from "@/lib/electron";
import HostManagerApp from "./ui/desktop/apps/host-manager/HostManagerApp.tsx";
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx"; const DesktopApp = lazy(() => import("@/ui/desktop/DesktopApp.tsx"));
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx"; const MobileApp = lazy(() =>
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx"; import("@/ui/mobile/MobileApp.tsx").then((module) => ({
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx"; default: module.MobileApp,
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx"; })),
import GuacamoleApp from "@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx"; );
const HostManagerApp = lazy(
() => import("./ui/desktop/apps/host-manager/HostManagerApp.tsx"),
);
const TerminalApp = lazy(
() => import("./ui/desktop/apps/features/terminal/TerminalApp.tsx"),
);
const FileManagerApp = lazy(
() => import("./ui/desktop/apps/features/file-manager/FileManagerApp.tsx"),
);
const TunnelApp = lazy(
() => import("./ui/desktop/apps/features/tunnel/TunnelApp.tsx"),
);
const ServerStatsApp = lazy(
() => import("./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx"),
);
const DockerApp = lazy(
() => import("./ui/desktop/apps/features/docker/DockerApp.tsx"),
);
const GuacamoleApp = lazy(
() => import("@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx"),
);
const ElectronVersionCheck = lazy(() =>
import("@/ui/desktop/user/ElectronVersionCheck.tsx").then((module) => ({
default: module.ElectronVersionCheck,
})),
);
const FullscreenApp: React.FC = () => { const FullscreenApp: React.FC = () => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -96,7 +120,10 @@ function RootApp() {
useServiceWorker(); useServiceWorker();
const userAgent = const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || ""; navigator.userAgent ||
navigator.vendor ||
(window as Window & { opera?: string }).opera ||
"";
const isTermixMobile = /Termix-Mobile/.test(userAgent); const isTermixMobile = /Termix-Mobile/.test(userAgent);
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
@@ -141,22 +168,26 @@ function RootApp() {
)} )}
<div className="relative min-h-screen" style={{ zIndex: 1 }}> <div className="relative min-h-screen" style={{ zIndex: 1 }}>
{isElectron() && showVersionCheck && !isFullscreen ? ( {isElectron() && showVersionCheck && !isFullscreen ? (
<Suspense fallback={null}>
<ElectronVersionCheck <ElectronVersionCheck
onContinue={() => setShowVersionCheck(false)} onContinue={() => setShowVersionCheck(false)}
isAuthenticated={false} isAuthenticated={false}
/> />
</Suspense>
) : ( ) : (
renderApp() <Suspense fallback={null}>{renderApp()}</Suspense>
)} )}
</div> </div>
</> </>
); );
} }
createRoot(document.getElementById("root")!).render( prepareClientCacheVersion().finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp /> <RootApp />
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
); );
});
+1 -1
View File
@@ -32,7 +32,7 @@ export type LogEntry = {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
stage: ConnectionStage; stage: ConnectionStage;
message: string; message: string;
details?: Record<string, any>; details?: Record<string, unknown>;
}; };
export interface ConnectionLogResponse { export interface ConnectionLogResponse {
+42 -2
View File
@@ -32,6 +32,46 @@ export interface ElectronAPI {
getServerConfig: () => Promise<ServerConfig>; getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>; saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>; testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
) => Promise<{ success: boolean; tunnelName?: string; error?: string }>;
testC2STunnel: (
tunnel: unknown,
index: number,
) => Promise<{ success: boolean; message?: string; error?: string }>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>; showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>; showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
@@ -89,8 +129,8 @@ declare global {
electronAPI: ElectronAPI; electronAPI: ElectronAPI;
IS_ELECTRON: boolean; IS_ELECTRON: boolean;
electronClipboard?: { electronClipboard?: {
writeText(text: string): void; writeText(text: string): Promise<boolean>;
readText(): string; readText(): Promise<string>;
}; };
} }
} }
+39 -3
View File
@@ -1,5 +1,6 @@
import type { Client } from "ssh2"; import type { Client } from "ssh2";
import type { Request } from "express"; import type { Request } from "express";
import type { RefObject } from "react";
// ============================================================================ // ============================================================================
// HOST TYPES (SSH, RDP, VNC, Telnet) // HOST TYPES (SSH, RDP, VNC, Telnet)
@@ -248,11 +249,20 @@ export interface CredentialData {
// TUNNEL TYPES // TUNNEL TYPES
// ============================================================================ // ============================================================================
export type TunnelScope = "s2s" | "c2s";
export type TunnelMode = "local" | "remote" | "dynamic";
export interface TunnelConnection { export interface TunnelConnection {
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote"; tunnelType?: "local" | "remote";
bindHost?: string;
sourceHostId?: number;
sourceHostName?: string;
sourcePort: number; sourcePort: number;
endpointPort: number; endpointPort: number;
endpointHost: string; endpointHost?: string;
targetHost?: string;
endpointPassword?: string; endpointPassword?: string;
endpointKey?: string; endpointKey?: string;
@@ -267,7 +277,11 @@ export interface TunnelConnection {
export interface TunnelConfig { export interface TunnelConfig {
name: string; name: string;
scope?: TunnelScope;
mode?: TunnelMode;
tunnelType?: "local" | "remote"; tunnelType?: "local" | "remote";
bindHost?: string;
targetHost?: string;
sourceHostId: number; sourceHostId: number;
tunnelIndex: number; tunnelIndex: number;
@@ -311,6 +325,17 @@ export interface TunnelConfig {
socks5ProxyChain?: ProxyNode[]; socks5ProxyChain?: ProxyNode[];
} }
export interface C2STunnelPreset {
id: number;
userId: string;
name: string;
config: TunnelConnection[];
platform?: string | null;
computerName?: string | null;
createdAt: string;
updatedAt: string;
}
export interface TunnelStatus { export interface TunnelStatus {
connected: boolean; connected: boolean;
status: ConnectionState; status: ConnectionState;
@@ -325,7 +350,7 @@ export interface TunnelStatus {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
stage: string; stage: string;
message: string; message: string;
details?: Record<string, any>; details?: Record<string, unknown>;
}>; }>;
} }
@@ -468,12 +493,22 @@ export interface TabContextTab {
| "telnet"; | "telnet";
title: string; title: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: RefObject<TerminalRefHandle | null>;
initialTab?: string; initialTab?: string;
_updateTimestamp?: number; _updateTimestamp?: number;
connectionConfig?: Record<string, unknown>; connectionConfig?: Record<string, unknown>;
} }
export interface TerminalRefHandle {
disconnect?: () => void;
reconnect?: () => void;
fit?: () => void;
sendInput?: (data: string) => void;
notifyResize?: () => void;
refresh?: () => void;
openFileManager?: () => void;
}
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
export interface SplitConfiguration { export interface SplitConfiguration {
@@ -715,6 +750,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId: string; userId: string;
sessionId?: string;
user?: { user?: {
id: string; id: string;
username: string; username: string;
+2 -2
View File
@@ -80,7 +80,7 @@ export function ServerStatusProvider({
return prev; return prev;
}); });
return enabled; return enabled;
} catch (error) { } catch {
return new Set<number>(); return new Set<number>();
} }
}, [isAuthenticated]); }, [isAuthenticated]);
@@ -111,7 +111,7 @@ export function ServerStatusProvider({
} }
setStatuses(newStatuses); setStatuses(newStatuses);
} catch (error) { } catch {
if (mountedRef.current) { if (mountedRef.current) {
setStatuses((prev) => { setStatuses((prev) => {
const updated = new Map(prev); const updated = new Map(prev);
+149 -21
View File
@@ -1,16 +1,15 @@
import React, { import React, {
useState,
useEffect,
useCallback, useCallback,
useRef,
Component, Component,
type ErrorInfo, Suspense,
lazy,
type ReactNode, type ReactNode,
useEffect,
useRef,
useState,
} from "react"; } from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
import { import {
TabProvider, TabProvider,
useTabs, useTabs,
@@ -18,16 +17,47 @@ import {
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx"; import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext"; import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
import { toast } from "sonner"; import { toast } from "sonner";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import {
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts"; getUserInfo,
logoutUser,
isCurrentAuthInvalidationError,
} from "@/ui/main-axios.ts";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
const Dashboard = lazy(() =>
import("@/ui/desktop/apps/dashboard/Dashboard.tsx").then((module) => ({
default: module.Dashboard,
})),
);
const HostManager = lazy(() =>
import("@/ui/desktop/apps/host-manager/hosts/HostManager.tsx").then(
(module) => ({
default: module.HostManager,
}),
),
);
const AdminSettings = lazy(() =>
import("@/ui/desktop/apps/admin/AdminSettings.tsx").then((module) => ({
default: module.AdminSettings,
})),
);
const UserProfile = lazy(() =>
import("@/ui/desktop/user/UserProfile.tsx").then((module) => ({
default: module.UserProfile,
})),
);
const CommandPalette = lazy(() =>
import("@/ui/desktop/apps/command-palette/CommandPalette.tsx").then(
(module) => ({
default: module.CommandPalette,
}),
),
);
function AppContent({ function AppContent({
onAuthStateChange, onAuthStateChange,
@@ -52,6 +82,7 @@ function AppContent({
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400); const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const isAuthenticatedRef = useRef(false);
const isDarkMode = const isDarkMode =
theme === "dark" || theme === "dark" ||
@@ -96,6 +127,8 @@ function AppContent({
const handleSessionExpired = () => { const handleSessionExpired = () => {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
}; };
dbHealthMonitor.on( dbHealthMonitor.on(
@@ -177,9 +210,8 @@ function AppContent({
if (hostIdentifier) { if (hostIdentifier) {
const openTerminal = async () => { const openTerminal = async () => {
try { try {
const { getSSHHostById, getSSHHosts } = await import( const { getSSHHostById, getSSHHosts } =
"@/ui/main-axios.ts" await import("@/ui/main-axios.ts");
);
let host = null; let host = null;
if (/^\d+$/.test(hostIdentifier)) { if (/^\d+$/.test(hostIdentifier)) {
@@ -211,6 +243,22 @@ function AppContent({
}, [addTab]); }, [addTab]);
const isCheckingAuth = useRef(false); const isCheckingAuth = useRef(false);
const clientTunnelAutoStartStarted = useRef(false);
const startClientTunnelAutoStart = useCallback(() => {
if (
clientTunnelAutoStartStarted.current ||
!window.electronAPI?.isElectron
) {
return;
}
clientTunnelAutoStartStarted.current = true;
window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => {
clientTunnelAutoStartStarted.current = false;
console.error("Failed to start client tunnel auto-start entries:", error);
});
}, []);
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
@@ -227,16 +275,22 @@ function AppContent({
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin); setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null); setUsername(meRes.username || null);
startClientTunnelAutoStart();
} }
}) })
.catch((err) => { .catch((err) => {
if (isCurrentAuthInvalidationError(err)) {
setIsAuthenticated(false); setIsAuthenticated(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again"); console.warn("Session expired - please log in again");
return;
}
if (!isAuthenticatedRef.current) {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
} }
}) })
.finally(() => { .finally(() => {
@@ -251,7 +305,7 @@ function AppContent({
window.addEventListener("storage", handleStorageChange); window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange);
}, []); }, [startClientTunnelAutoStart]);
useEffect(() => { useEffect(() => {
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen)); localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
@@ -259,6 +313,7 @@ function AppContent({
useEffect(() => { useEffect(() => {
onAuthStateChange?.(isAuthenticated); onAuthStateChange?.(isAuthenticated);
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated, onAuthStateChange]); }, [isAuthenticated, onAuthStateChange]);
const handleAuthSuccess = useCallback( const handleAuthSuccess = useCallback(
@@ -274,6 +329,7 @@ function AppContent({
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(authData.isAdmin); setIsAdmin(authData.isAdmin);
setUsername(authData.username); setUsername(authData.username);
startClientTunnelAutoStart();
setTransitionPhase("fadeIn"); setTransitionPhase("fadeIn");
setTimeout(() => { setTimeout(() => {
@@ -282,7 +338,7 @@ function AppContent({
}, 800); }, 800);
}, 1200); }, 1200);
}, },
[], [startClientTunnelAutoStart],
); );
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
@@ -347,18 +403,22 @@ function AppContent({
return ( return (
<div className="h-screen w-screen overflow-hidden bg-background"> <div className="h-screen w-screen overflow-hidden bg-background">
<Suspense fallback={null}>
<CommandPalette <CommandPalette
isOpen={isCommandPaletteOpen} isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen} setIsOpen={setIsCommandPaletteOpen}
/> />
</Suspense>
{!isAuthenticated && ( {!isAuthenticated && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background"> <div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
<Suspense fallback={null}>
<Dashboard <Dashboard
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
authLoading={authLoading} authLoading={authLoading}
onAuthSuccess={handleAuthSuccess} onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
/> />
</Suspense>
</div> </div>
)} )}
@@ -382,6 +442,22 @@ function AppContent({
{showHome && ( {showHome && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<Dashboard <Dashboard
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
authLoading={authLoading} authLoading={authLoading}
@@ -390,11 +466,28 @@ function AppContent({
rightSidebarOpen={rightSidebarOpen} rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth} rightSidebarWidth={rightSidebarWidth}
/> />
</Suspense>
</div> </div>
)} )}
{showSshManager && ( {showSshManager && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<HostManager <HostManager
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab} initialTab={currentTabData?.initialTab}
@@ -405,33 +498,68 @@ function AppContent({
currentTabId={currentTab} currentTabId={currentTab}
updateTab={updateTab} updateTab={updateTab}
/> />
</Suspense>
</div> </div>
)} )}
{showAdmin && ( {showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<AdminSettings <AdminSettings
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen} rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth} rightSidebarWidth={rightSidebarWidth}
/> />
</Suspense>
</div> </div>
)} )}
{showProfile && ( {showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto thin-scrollbar"> <div className="h-screen w-full visible pointer-events-auto static overflow-auto thin-scrollbar">
<Suspense
fallback={
<div
className="bg-canvas rounded-lg border-2 border-edge relative"
style={{
margin: "74px 17px 8px 8px",
height: "calc(100vh - 82px)",
}}
>
<SimpleLoader
visible={true}
message={t("common.loading")}
/>
</div>
}
>
<UserProfile <UserProfile
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen} rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth} rightSidebarWidth={rightSidebarWidth}
initialTab={currentTabData?.initialTab}
/> />
</Suspense>
</div> </div>
)} )}
<TopNavbar <TopNavbar
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
onRightSidebarStateChange={(isOpen, width) => { onRightSidebarStateChange={(isOpen, width) => {
setRightSidebarOpen(isOpen); setRightSidebarOpen(isOpen);
setRightSidebarWidth(width); setRightSidebarWidth(width);
@@ -643,7 +771,7 @@ class TabErrorBoundary extends Component<
throw error; throw error;
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo) { componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
if (error.message?.includes("useTabs must be used within a TabProvider")) { if (error.message?.includes("useTabs must be used within a TabProvider")) {
console.warn( console.warn(
"TabProvider mounting race condition detected, recovering...", "TabProvider mounting race condition detected, recovering...",
+15 -9
View File
@@ -6,6 +6,7 @@ import { getSSHHosts, getUserInfo } from "@/ui/main-axios.ts";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
interface FullScreenAppWrapperProps { interface FullScreenAppWrapperProps {
hostId?: string; hostId?: string;
@@ -20,7 +21,18 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false); const [, setIsAdmin] = useState(false);
useEffect(() => {
const handleSessionExpired = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setHostConfig(null);
};
dbHealthMonitor.on("session-expired", handleSessionExpired);
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
}, []);
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
@@ -28,9 +40,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
const userInfo = await getUserInfo(); const userInfo = await getUserInfo();
if (userInfo) { if (userInfo) {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(userInfo.isAdmin || false);
} }
} catch (error) { } catch {
setIsAuthenticated(false); setIsAuthenticated(false);
} finally { } finally {
setAuthLoading(false); setAuthLoading(false);
@@ -65,13 +76,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
} }
}, [hostId, isAuthenticated, authLoading]); }, [hostId, isAuthenticated, authLoading]);
const handleAuthSuccess = (authData: { const handleAuthSuccess = () => {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(authData.isAdmin);
window.location.reload(); window.location.reload();
}; };
+30 -7
View File
@@ -7,7 +7,7 @@ import {
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs.tsx"; } from "@/components/ui/tabs.tsx";
import { Shield, Users, Database, Clock } from "lucide-react"; import { Shield, Users, Database, Clock, Key } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -22,12 +22,14 @@ import {
getSessions, getSessions,
unlinkOIDCFromPasswordAccount, unlinkOIDCFromPasswordAccount,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx"; import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx";
import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx"; import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx";
import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx"; import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx";
import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx"; import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx";
import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx"; import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx";
import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx"; import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx";
import { ApiKeysTab } from "@/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx";
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx"; import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx"; import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx"; import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
@@ -47,6 +49,7 @@ export function AdminSettings({
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const [loading, setLoading] = React.useState(true);
const [allowRegistration, setAllowRegistration] = React.useState(true); const [allowRegistration, setAllowRegistration] = React.useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true); const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
@@ -102,8 +105,8 @@ export function AdminSettings({
createdAt: string; createdAt: string;
expiresAt: string; expiresAt: string;
lastActiveAt: string; lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean; isRevoked?: boolean;
isCurrentSession?: boolean;
}> }>
>([]); >([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false); const [sessionsLoading, setSessionsLoading] = React.useState(false);
@@ -119,10 +122,12 @@ export function AdminSettings({
const serverUrl = (window as { configuredServerUrl?: string }) const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl; .configuredServerUrl;
if (!serverUrl) { if (!serverUrl) {
setLoading(false);
return; return;
} }
} }
Promise.allSettled([
getAdminOIDCConfig() getAdminOIDCConfig()
.then((res) => { .then((res) => {
if (res) setOidcConfig(res); if (res) setOidcConfig(res);
@@ -131,7 +136,7 @@ export function AdminSettings({
if (!err.message?.includes("No server configured")) { if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchOidcConfig")); toast.error(t("admin.failedToFetchOidcConfig"));
} }
}); }),
getUserInfo() getUserInfo()
.then((info) => { .then((info) => {
if (info) { if (info) {
@@ -147,8 +152,15 @@ export function AdminSettings({
if (!err?.message?.includes("No server configured")) { if (!err?.message?.includes("No server configured")) {
console.warn("Failed to fetch current user info", err); console.warn("Failed to fetch current user info", err);
} }
}); }),
fetchSessions(); getSessions()
.then((data) => setSessions(data.sessions || []))
.catch((err) => {
if (!err?.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchSessions"));
}
}),
]).finally(() => setLoading(false));
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
@@ -332,6 +344,7 @@ export function AdminSettings({
style={wrapperStyle} style={wrapperStyle}
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden" className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
> >
<SimpleLoader visible={loading} message={t("common.loading")} />
<div className="h-full w-full flex flex-col"> <div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2"> <div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t("admin.title")}</h1> <h1 className="font-bold text-lg">{t("admin.title")}</h1>
@@ -391,6 +404,13 @@ export function AdminSettings({
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
{t("admin.databaseSecurity")} {t("admin.databaseSecurity")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="api-keys"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Key className="h-4 w-4" />
{t("admin.apiKeys.tabLabel")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="registration" className="space-y-6"> <TabsContent value="registration" className="space-y-6">
@@ -441,7 +461,11 @@ export function AdminSettings({
</TabsContent> </TabsContent>
<TabsContent value="security" className="space-y-6"> <TabsContent value="security" className="space-y-6">
<DatabaseSecurityTab currentUser={currentUser} /> <DatabaseSecurityTab />
</TabsContent>
<TabsContent value="api-keys" className="space-y-6">
<ApiKeysTab />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -459,7 +483,6 @@ export function AdminSettings({
user={selectedUserForEdit} user={selectedUserForEdit}
currentUser={currentUser} currentUser={currentUser}
onSuccess={handleEditUserSuccess} onSuccess={handleEditUserSuccess}
allowPasswordLogin={allowPasswordLogin}
/> />
<LinkAccountDialog <LinkAccountDialog
@@ -5,7 +5,6 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from "@/components/ui/dialog.tsx"; } from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label.tsx";
@@ -20,7 +19,6 @@ import {
Plus, Plus,
AlertCircle, AlertCircle,
Shield, Shield,
Key,
Clock, Clock,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -32,7 +30,6 @@ import {
removeRoleFromUser, removeRoleFromUser,
makeUserAdmin, makeUserAdmin,
removeAdminStatus, removeAdminStatus,
initiatePasswordReset,
revokeAllUserSessions, revokeAllUserSessions,
deleteUser, deleteUser,
type UserRole, type UserRole,
@@ -53,7 +50,6 @@ interface UserEditDialogProps {
user: User | null; user: User | null;
currentUser: { id: string; username: string } | null; currentUser: { id: string; username: string } | null;
onSuccess: () => void; onSuccess: () => void;
allowPasswordLogin: boolean;
} }
export function UserEditDialog({ export function UserEditDialog({
@@ -62,13 +58,11 @@ export function UserEditDialog({
user, user,
currentUser, currentUser,
onSuccess, onSuccess,
allowPasswordLogin,
}: UserEditDialogProps) { }: UserEditDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const [adminLoading, setAdminLoading] = useState(false); const [adminLoading, setAdminLoading] = useState(false);
const [passwordResetLoading, setPasswordResetLoading] = useState(false);
const [sessionLoading, setSessionLoading] = useState(false); const [sessionLoading, setSessionLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false); const [rolesLoading, setRolesLoading] = useState(false);
@@ -160,42 +154,6 @@ export function UserEditDialog({
} }
}; };
const handlePasswordReset = async () => {
if (!user) return;
const userToReset = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.resetUserPassword"),
description: `${t("admin.passwordResetWarning")} (${userToReset.username})`,
confirmText: t("admin.resetUserPassword"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setPasswordResetLoading(true);
try {
await initiatePasswordReset(userToReset.username);
toast.success(
t("admin.passwordResetInitiated", { username: userToReset.username }),
);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to reset password:", error);
toast.error(t("admin.failedToResetPassword"));
onOpenChange(true);
} finally {
setPasswordResetLoading(false);
}
};
const handleAssignRole = async (roleId: number) => { const handleAssignRole = async (roleId: number) => {
if (!user) return; if (!user) return;
@@ -342,9 +300,6 @@ export function UserEditDialog({
if (!user) return null; if (!user) return null;
const showPasswordReset =
allowPasswordLogin && (user.passwordHash || !user.isOidc);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge"> <DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
@@ -0,0 +1,507 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import {
Key,
Plus,
Trash2,
Copy,
Check,
ChevronsUpDown,
AlertCircle,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getApiKeys,
createApiKey,
deleteApiKey,
getUserList,
type ApiKey,
type CreatedApiKey,
} from "@/ui/main-axios.ts";
interface UserOption {
id: string;
username: string;
}
function UserCombobox({
users,
value,
onChange,
disabled,
}: {
users: UserOption[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const selected = users.find((u) => u.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
disabled={disabled}
>
{selected ? selected.username : t("admin.apiKeys.selectUser")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder={t("admin.apiKeys.searchUsers")} />
<CommandList>
<CommandEmpty>{t("admin.apiKeys.noUsersFound")}</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-y-auto thin-scrollbar">
{users.map((user) => (
<CommandItem
key={user.id}
value={user.username}
onSelect={() => {
onChange(user.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === user.id ? "opacity-100" : "opacity-0",
)}
/>
{user.username}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function CreateApiKeyDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = React.useState("");
const [selectedUserId, setSelectedUserId] = React.useState("");
const [expiresAt, setExpiresAt] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [usersLoading, setUsersLoading] = React.useState(false);
const [users, setUsers] = React.useState<UserOption[]>([]);
const [createdKey, setCreatedKey] = React.useState<CreatedApiKey | null>(
null,
);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!open) return;
if (createdKey) return;
setUsersLoading(true);
getUserList()
.then((res) =>
setUsers(
res.users.map((u) => ({
id: (u as unknown as { id: string }).id,
username: u.username,
})),
),
)
.catch(() => toast.error(t("admin.failedToFetchUsers")))
.finally(() => setUsersLoading(false));
}, [open]);
const handleClose = () => {
setCreatedKey(null);
setName("");
setSelectedUserId("");
setExpiresAt("");
setCopied(false);
onOpenChange(false);
onCreated();
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error(t("admin.apiKeys.nameRequired"));
return;
}
if (!selectedUserId) {
toast.error(t("admin.apiKeys.userRequired"));
return;
}
setLoading(true);
try {
const result = await createApiKey(
name.trim(),
selectedUserId,
expiresAt || undefined,
);
setCreatedKey(result);
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } };
toast.error(
e?.response?.data?.error || t("admin.apiKeys.failedToCreate"),
);
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!createdKey) return;
await navigator.clipboard.writeText(createdKey.token);
setCopied(true);
toast.success(t("admin.apiKeys.tokenCopied"));
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) handleClose();
}}
>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
{createdKey
? t("admin.apiKeys.keyCreated")
: t("admin.apiKeys.createApiKey")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{createdKey
? t("admin.apiKeys.keyCreatedDescription")
: t("admin.apiKeys.createApiKeyDescription")}
</DialogDescription>
</DialogHeader>
{!createdKey ? (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("admin.apiKeys.keyName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("admin.apiKeys.keyNamePlaceholder")}
disabled={loading}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>{t("admin.apiKeys.scopedUser")}</Label>
{usersLoading ? (
<p className="text-sm text-muted-foreground">
{t("admin.loading")}
</p>
) : (
<UserCombobox
users={users}
value={selectedUserId}
onChange={setSelectedUserId}
disabled={loading}
/>
)}
</div>
<div className="space-y-2">
<Label>
{t("admin.apiKeys.expiresAt")}{" "}
<span className="text-muted-foreground text-xs">
({t("admin.apiKeys.optional")})
</span>
</Label>
<Input
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={loading}
min={new Date().toISOString().split("T")[0]}
/>
<p className="text-xs text-muted-foreground">
{t("admin.apiKeys.expiresAtHelp")}
</p>
</div>
</div>
) : (
<div className="space-y-4 py-4">
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-900 dark:text-yellow-200">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<AlertTitle>{t("admin.apiKeys.copyWarningTitle")}</AlertTitle>
<AlertDescription>
{t("admin.apiKeys.copyWarningDescription")}
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>{t("admin.apiKeys.apiKey")}</Label>
<div className="flex gap-2 items-start">
<code className="flex-1 block rounded bg-muted px-3 py-2 text-xs font-mono break-all border border-edge">
{createdKey.token}
</code>
<Button
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
{!createdKey ? (
<>
<Button
variant="outline"
onClick={handleClose}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleCreate} disabled={loading || usersLoading}>
{loading
? t("admin.apiKeys.creating")
: t("admin.apiKeys.createApiKey")}
</Button>
</>
) : (
<Button onClick={handleClose}>{t("common.done")}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApiKeysTab(): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [keys, setKeys] = React.useState<ApiKey[]>([]);
const [loading, setLoading] = React.useState(false);
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const fetchKeys = React.useCallback(async () => {
setLoading(true);
try {
const data = await getApiKeys();
setKeys(data.apiKeys);
} catch {
toast.error(t("admin.apiKeys.failedToFetch"));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
fetchKeys();
}, [fetchKeys]);
const handleDelete = (keyId: string, keyName: string) => {
confirmWithToast(
t("admin.apiKeys.confirmRevoke", { name: keyName }),
async () => {
try {
await deleteApiKey(keyId);
toast.success(t("admin.apiKeys.revokedSuccessfully"));
fetchKeys();
} catch {
toast.error(t("admin.apiKeys.failedToRevoke"));
}
},
"destructive",
);
};
const formatDate = (iso: string | null) => {
if (!iso) return t("admin.apiKeys.never");
const d = new Date(iso);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
};
const isExpired = (expiresAt: string | null) =>
expiresAt ? new Date(expiresAt) < new Date() : false;
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("admin.apiKeys.title")}</h3>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/api-keys", "_blank")
}
>
{t("common.documentation")}
</Button>
<Button
onClick={fetchKeys}
disabled={loading}
variant="outline"
size="sm"
>
<RefreshCw
className={cn("h-4 w-4 mr-1", loading && "animate-spin")}
/>
{loading ? t("admin.loading") : t("admin.refresh")}
</Button>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t("admin.apiKeys.createApiKey")}
</Button>
</div>
</div>
{loading && keys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.loading")}
</div>
) : keys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.apiKeys.noKeys")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.apiKeys.name")}</TableHead>
<TableHead>{t("admin.user")}</TableHead>
<TableHead>{t("admin.apiKeys.prefix")}</TableHead>
<TableHead>{t("admin.created")}</TableHead>
<TableHead>{t("admin.expires")}</TableHead>
<TableHead>{t("admin.apiKeys.lastUsed")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.id}>
<TableCell className="px-4 font-medium">
{key.name}
{!key.isActive && (
<Badge variant="destructive" className="ml-2 text-xs">
{t("admin.revoked")}
</Badge>
)}
</TableCell>
<TableCell className="px-4">
{key.username || key.userId}
</TableCell>
<TableCell className="px-4">
<code className="text-xs bg-muted px-1 py-0.5 rounded">
{key.tokenPrefix}
</code>
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(key.createdAt)}
</TableCell>
<TableCell className="px-4 text-sm">
<span
className={
isExpired(key.expiresAt)
? "text-red-500"
: "text-muted-foreground"
}
>
{formatDate(key.expiresAt)}
</span>
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(key.lastUsedAt)}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title={t("admin.apiKeys.revokeKey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<CreateApiKeyDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreated={fetchKeys}
/>
</div>
);
}
@@ -6,15 +6,7 @@ import { toast } from "sonner";
import { isElectron } from "@/ui/main-axios.ts"; import { isElectron } from "@/ui/main-axios.ts";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
interface DatabaseSecurityTabProps { export function DatabaseSecurityTab(): React.ReactElement {
currentUser: {
is_oidc: boolean;
} | null;
}
export function DatabaseSecurityTab({
currentUser,
}: DatabaseSecurityTabProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [exportLoading, setExportLoading] = React.useState(false); const [exportLoading, setExportLoading] = React.useState(false);
@@ -42,12 +34,6 @@ export function DatabaseSecurityTab({
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
}; };
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
@@ -110,17 +96,8 @@ export function DatabaseSecurityTab({
const formData = new FormData(); const formData = new FormData();
formData.append("file", importFile); formData.append("file", importFile);
const importHeaders: Record<string, string> = {};
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
importHeaders["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: importHeaders,
credentials: "include", credentials: "include",
body: formData, body: formData,
}); });
@@ -73,7 +73,7 @@ export function GeneralSettingsTab({
const [logLevel, setLogLevel] = React.useState("info"); const [logLevel, setLogLevel] = React.useState("info");
const [logLevelLoading, setLogLevelLoading] = React.useState(false); const [logLevelLoading, setLogLevelLoading] = React.useState(false);
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24); const [, setSessionTimeoutHours] = React.useState(24);
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24"); const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
const [sessionTimeoutLoading, setSessionTimeoutLoading] = const [sessionTimeoutLoading, setSessionTimeoutLoading] =
React.useState(false); React.useState(false);
+2 -2
View File
@@ -108,7 +108,7 @@ export function RolesTab(): React.ReactElement {
setRoleDialogOpen(false); setRoleDialogOpen(false);
loadRoles(); loadRoles();
} catch (error) { } catch {
toast.error(t("rbac.failedToSaveRole")); toast.error(t("rbac.failedToSaveRole"));
} }
}; };
@@ -129,7 +129,7 @@ export function RolesTab(): React.ReactElement {
await deleteRole(role.id); await deleteRole(role.id);
toast.success(t("rbac.roleDeletedSuccessfully")); toast.success(t("rbac.roleDeletedSuccessfully"));
loadRoles(); loadRoles();
} catch (error) { } catch {
toast.error(t("rbac.failedToDeleteRole")); toast.error(t("rbac.failedToDeleteRole"));
} }
}; };
@@ -12,11 +12,7 @@ import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { import { revokeSession, revokeAllUserSessions } from "@/ui/main-axios.ts";
getCookie,
revokeSession,
revokeAllUserSessions,
} from "@/ui/main-axios.ts";
interface Session { interface Session {
id: string; id: string;
@@ -27,8 +23,8 @@ interface Session {
createdAt: string; createdAt: string;
expiresAt: string; expiresAt: string;
lastActiveAt: string; lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean; isRevoked?: boolean;
isCurrentSession?: boolean;
} }
interface SessionManagementTabProps { interface SessionManagementTabProps {
@@ -46,9 +42,9 @@ export function SessionManagementTab({
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const handleRevokeSession = async (sessionId: string) => { const handleRevokeSession = async (sessionId: string) => {
const currentJWT = getCookie("jwt"); const isCurrentSession = sessions.some(
const currentSession = sessions.find((s) => s.jwtToken === currentJWT); (session) => session.id === sessionId && session.isCurrentSession,
const isCurrentSession = currentSession?.id === sessionId; );
confirmWithToast( confirmWithToast(
t("admin.confirmRevokeSession"), t("admin.confirmRevokeSession"),
@@ -8,13 +8,12 @@ import {
} from "@/components/ui/command.tsx"; } from "@/components/ui/command.tsx";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { Kbd, KbdKey, KbdSeparator } from "@/components/ui/kbd";
import { import {
Key, Key,
Server, Server,
Settings, Settings,
User, User,
Github,
Terminal, Terminal,
Monitor, Monitor,
Eye, Eye,
@@ -28,6 +27,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BiMoney, BiSupport } from "react-icons/bi"; import { BiMoney, BiSupport } from "react-icons/bi";
import { BsDiscord } from "react-icons/bs"; import { BsDiscord } from "react-icons/bs";
import { FaGithub } from "react-icons/fa";
import { GrUpdate } from "react-icons/gr"; import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { import {
@@ -38,7 +38,6 @@ import {
logActivity, logActivity,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts"; import type { RecentActivityItem } from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { import {
DropdownMenu, DropdownMenu,
@@ -76,7 +75,7 @@ interface SSHHost {
domain?: string; domain?: string;
security?: string; security?: string;
ignoreCert?: boolean; ignoreCert?: boolean;
guacamoleConfig?: any; guacamoleConfig?: unknown;
showTerminalInSidebar?: boolean; showTerminalInSidebar?: boolean;
showFileManagerInSidebar?: boolean; showFileManagerInSidebar?: boolean;
showTunnelInSidebar?: boolean; showTunnelInSidebar?: boolean;
@@ -84,6 +83,28 @@ interface SSHHost {
showServerStatsInSidebar?: boolean; showServerStatsInSidebar?: boolean;
} }
function shouldShowMetrics(host: SSHHost): boolean {
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
return statsConfig.metricsEnabled !== false;
} catch {
return true;
}
}
function hasTunnelConnections(host: SSHHost): boolean {
try {
const tunnelConnections = Array.isArray(host.tunnelConnections)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
} catch {
return false;
}
}
export function CommandPalette({ export function CommandPalette({
isOpen, isOpen,
setIsOpen, setIsOpen,
@@ -306,9 +327,6 @@ export function CommandPalette({
}; };
const handleHostEditClick = (host: SSHHost) => { const handleHostEditClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ addTab({
type: "ssh_manager", type: "ssh_manager",
title: t("commandPalette.hostManager"), title: t("commandPalette.hostManager"),
@@ -391,32 +409,10 @@ export function CommandPalette({
? host.name ? host.name
: `${host.username}@${host.ip}:${host.port}`; : `${host.username}@${host.ip}:${host.port}`;
let shouldShowMetrics = true;
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
shouldShowMetrics = statsConfig.metricsEnabled !== false;
} catch {
shouldShowMetrics = true;
}
const isSSH = const isSSH =
!host.connectionType || host.connectionType === "ssh"; !host.connectionType || host.connectionType === "ssh";
const showMetrics = shouldShowMetrics(host);
let hasTunnelConnections = false; const hasTunnels = hasTunnelConnections(host);
try {
const tunnelConnections = Array.isArray(
host.tunnelConnections,
)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
hasTunnelConnections =
Array.isArray(tunnelConnections) &&
tunnelConnections.length > 0;
} catch {
hasTunnelConnections = false;
}
const visibleButtons = [ const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true), host.enableTerminal && (host.showTerminalInSidebar ?? true),
@@ -425,13 +421,13 @@ export function CommandPalette({
(host.showFileManagerInSidebar ?? false), (host.showFileManagerInSidebar ?? false),
isSSH && isSSH &&
host.enableTunnel && host.enableTunnel &&
hasTunnelConnections && hasTunnels &&
(host.showTunnelInSidebar ?? false), (host.showTunnelInSidebar ?? false),
isSSH && isSSH &&
host.enableDocker && host.enableDocker &&
(host.showDockerInSidebar ?? false), (host.showDockerInSidebar ?? false),
isSSH && isSSH &&
shouldShowMetrics && showMetrics &&
(host.showServerStatsInSidebar ?? false), (host.showServerStatsInSidebar ?? false),
].filter(Boolean).length; ].filter(Boolean).length;
@@ -493,7 +489,7 @@ export function CommandPalette({
{isSSH && {isSSH &&
host.enableTunnel && host.enableTunnel &&
hasTunnelConnections && hasTunnels &&
(host.showTunnelInSidebar ?? false) && ( (host.showTunnelInSidebar ?? false) && (
<Button <Button
variant="outline" variant="outline"
@@ -523,7 +519,7 @@ export function CommandPalette({
)} )}
{isSSH && {isSSH &&
shouldShowMetrics && showMetrics &&
(host.showServerStatsInSidebar ?? false) && ( (host.showServerStatsInSidebar ?? false) && (
<Button <Button
variant="outline" variant="outline"
@@ -580,7 +576,7 @@ export function CommandPalette({
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isSSH && {isSSH &&
shouldShowMetrics && showMetrics &&
!(host.showServerStatsInSidebar ?? false) && ( !(host.showServerStatsInSidebar ?? false) && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -613,7 +609,7 @@ export function CommandPalette({
)} )}
{isSSH && {isSSH &&
host.enableTunnel && host.enableTunnel &&
hasTunnelConnections && hasTunnels &&
!(host.showTunnelInSidebar ?? false) && ( !(host.showTunnelInSidebar ?? false) && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -666,7 +662,7 @@ export function CommandPalette({
)} )}
<CommandGroup heading={t("commandPalette.links")}> <CommandGroup heading={t("commandPalette.links")}>
<CommandItem onSelect={handleGitHub}> <CommandItem onSelect={handleGitHub}>
<Github /> <FaGithub />
<span>{t("commandPalette.github")}</span> <span>{t("commandPalette.github")}</span>
</CommandItem> </CommandItem>
<CommandItem onSelect={handleSupport}> <CommandItem onSelect={handleSupport}>
@@ -686,10 +682,11 @@ export function CommandPalette({
<div className="border-t border-edge px-4 py-2 bg-hover/50 flex items-center justify-between text-xs text-muted-foreground"> <div className="border-t border-edge px-4 py-2 bg-hover/50 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{t("commandPalette.press")}</span> <span>{t("commandPalette.press")}</span>
<KbdGroup> <Kbd>
<Kbd>Shift</Kbd> <KbdKey>Shift</KbdKey>
<Kbd>Shift</Kbd> <KbdSeparator />
</KbdGroup> <KbdKey>Shift</KbdKey>
</Kbd>
<span>{t("commandPalette.toToggle")}</span> <span>{t("commandPalette.toToggle")}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

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